Ramiz RajaBook a call
← Writing
The App-Rescue Playbook

Call, Text, and Share Without Asking for a Single Permission

The obvious way to add a call button, a text button, and a save-to-contacts button reaches for permissions the app never needs. On Android one of them can get you pulled from the store; on iOS it puts you on record as touching data you never read. Here's the version that holds none of them — on both platforms.

Ramiz RajaAugust 25, 20268 min read
Call, Text, and Share Without Asking for a Single Permission

There is a version of this feature that can get your app removed from the Play Store, and it's the version most teams reach for first.

The feature is small. Under a phone number, a row of quick actions: call it, text it, copy it, save it to the address book, share it. Five buttons no user would think twice about, and none of them the point of the app — just the friction you remove so someone can act on a number the moment they have it.

The obvious way to build the first three reaches for a permission on each platform: on Android, one to place calls, one to send text messages, one to write contacts; on iOS, a Contacts usage description and an authorization prompt for the address book. Wire it up that way and you have quietly put a system prompt — or, on Android's SMS path, a store-policy tripwire — in front of a button a user tapped because they were ready to act. None of it is necessary. Every one of these actions can be done holding none of those permissions, on either platform, because none of them is something your app has to do itself. Each is something it can hand to the operating system, fully formed, with the user's finger on the trigger. The mechanics differ between Android and iOS; the discipline is identical.

What a permission actually costs here

A permission costs two things, and both come due at the worst possible moment. The first is conversion: a dangerous Android permission — placing calls, writing contacts — is granted through a system dialog that fires the instant the button is pressed, putting a "not now" in front of the exact tap that signalled intent. And a permanently declined permission is stickier than a declined action — until the user digs into a settings screen the button now does nothing, so the feature is quietly dead for them and they don't know why.

The second price is steeper, and on Android only the text button pays it. The permission to send SMS directly sits on the short list of restricted permissions the Play Store guards by policy — reserved for a narrow set of approved core uses, commonly the device's default SMS, Phone, or Assistant handler plus limited documented exceptions, none of which is "a convenience button." Request it as a mere convenience and you are not risking a warning; you are risking a rejected submission or an app taken down.

iOS charges the bill differently. It won't stop you from opening a tel: or sms: URL — no app authorization prompt, no entitlement — so calling and texting are free there by construction (the Call? confirmation on a tel: tap is iOS's own, not a permission gate). Where iOS makes you pay is the address book: reading or writing through the contact store means declaring NSContactsUsageDescription and getting the user's authorization — a declared capability, and an authorization prompt the user sees, that you've now taken on for a feature that only ever needed to add one. On both platforms the fix is the same shape: every contact action has a permission-free path that produces the identical result the user wanted.

The actionThe permission-hungry wayAndroid, permission-freeiOS, permission-free
Call the numberdirect-call API + a call permission — dials immediatelyACTION_DIAL with a tel: URI — dialer opens, number filleda tel: URL — iOS shows its own Call? confirmation, then dials
Text the numberSMS-send API + SEND_SMS (restricted on Play) — sends silentlyACTION_SENDTO with an smsto: URI — messaging app opens, filledan sms: URL — Messages opens, recipient filled
Save to contactsWRITE_CONTACTS / a contact-store write — saves silentlyACTION_INSERT into Contacts — a new-contact card opens, pre-filledserialize a vCard and hand it off — the user adds it to Contacts themselves
Share the number(often over-scoped by habit)ACTION_SEND via Intent.createChooser, E.164 textUIActivityViewController, E.164 text
Copy the numberthe clipboardthe clipboard

The claim this proves: none of the five actions is something the app must perform itself, on either platform. The four launch actions hand the OS a fully-formed request the user then completes; copy just writes the clipboard. Either way the permissions column stays empty — on Android no CALL_PHONE/SEND_SMS/WRITE_CONTACTS, on iOS no NSContactsUsageDescription and no call/messaging entitlement. (The table lists the permission-free mechanism each platform offers for an action; whether a given screen exposes all five is a separate product choice — this one drops the standalone text button on iOS, for a reason covered near the end.)

The call button that never places a call

The entire design lives in one distinction, and both platforms draw it. On Android, ACTION_CALL places the call the instant it fires, and the OS makes you hold CALL_PHONE to do that; ACTION_DIAL opens the dialer with the number already in it and then waits — for the user to press the green button. On iOS you don't even get the dangerous option for free: opening a tel: URL hands the number to the phone app, and iOS interposes its own Call this number? confirmation before anything dials. Same shape on both — the OS puts a screen with a commit button between your app and the call, and your app never holds the ability to place one on its own.

That one extra tap is the whole distinction between an app that can call people by itself — a capability a reviewer, and a user reading the permissions you now disclose, is right to be wary of — and a button that saved someone from typing a number. None of this makes the direct-call path illegitimate: a dialer, or an app whose whole job is placing calls, has every reason to hold CALL_PHONE. It's the wrong tool for a convenience button that has no business dialing on its own. So this app never places the call — it composes the call and lets the user commit it. That principle repeats through every button in the row, and it's why nothing here can auto-send: no code path dispatches something irreversible without the OS's own screen in between.

Text and share: the format is half the feature

Texting works the same way on both, with one platform difference worth stating plainly: Android's ACTION_SENDTO with an smsto: URI opens whatever messaging app the user set as their default handler, while iOS's sms: URL opens Messages. Either way the result is the same — recipient filled, cursor waiting, no message leaving until they send it themselves. No SEND_SMS, no restricted-permission tripwire, no entitlement.

Sharing is where an easy detail quietly decides whether the feature is any good, and it's identical across platforms: Android's ACTION_SEND chooser and iOS's UIActivityViewController will both forward whatever string you hand them. So hand it the number in E.164 form — +, country code, subscriber number, no spaces or local-dialling quirks — whenever it might travel. A number shared as a bare local string may dial fine at home but can fail once it leaves its expected dialing context, so normalize to E.164 when you know the country context, and pass an ambiguous number through untouched rather than guessing one. You were never going to send the message yourself; the one thing you do own is passing on a number that works from anywhere.

Saving a contact: where the platforms genuinely diverge

The address book is the user's, not the app's — and this is the one action where Android and iOS don't mirror each other, so a real dual-platform treatment has to say how, not pretend they match.

On Android it's clean. WRITE_CONTACTS lets you add an entry while the user isn't looking — and it commits you to disclosing that your app can modify contacts. ACTION_INSERT instead opens the contacts app's own new-contact screen with the fields already populated; they glance at it and save. Identical outcome, minus a permission and minus a disclosure that makes a cautious user hesitate.

On iOS the address book is authorization-gated by design: any path that reads or writes through CNContactStore requires NSContactsUsageDescription and an access grant. (iOS 18 added a limited tier — the user can share only a chosen subset of contacts with an app rather than the whole book — but that's still a grant your app has to request and hold; the write-free hand-off below sidesteps the whole authorization question instead.) The point is that touching the store at all is a declared capability, so the permission-free move is to not touch it: build the contact in memory, serialize it to a vCard with CNContactVCardSerialization, and hand that file off — through a share sheet, or by opening it — for the user to add to Contacts themselves. Your app produces a contact and lets the user import it; which destinations the OS surfaces for a .vcf is the user's business, not the app's. What the app guarantees is its own side of the contract: it never reads or writes CNContactStore and never declares the usage string. If a team instead reaches for an editor or save flow that goes through CNContactStore, that's the one place on iOS where this feature would have to ask — worth knowing precisely, because it's the difference between an honest platform adaptation and a permission you added without noticing.

One production nicety cuts across every button, on both platforms: each launch is guarded for the rare device or work profile with nothing registered to handle it — no dialer, no messaging app, no contacts editor — so a tap degrades gracefully instead of dead-ending on an ActivityNotFoundException or a silently-failed open.

Permission-free is an invariant, not a milestone

Here is the part that decides whether any of the above survives contact with a real team. A permission-free design is not a state you reach once. It is a promise a later, well-meaning change quietly revokes. The realistic way it breaks on Android: months on, someone adds a "quick call" that dials immediately because it feels snappier, swaps ACTION_DIAL for ACTION_CALL, adds CALL_PHONE to the manifest to stop the runtime SecurityException that swap now throws, and ships. Every screen still works. The build is green. Nothing looks wrong until a store reviewer, or your own data-safety audit, notices a permission that wasn't there last quarter — and by then it's live. The iOS version of the same drift is quieter: someone wires up a contact editor that saves through CNContactStore, adds NSContactsUsageDescription to make it work, and the app is now declaring it can touch contacts.

So the promise is pinned by checks that go red the moment the design drifts — one per platform, because the thing that leaks is different on each. On Android, the strongest artifact this feature has is an absence: the sensitive permissions that simply never show up in what ships.

# CI runs this after assembling the release build, and FAILS if any of the three
# sensitive permissions reached the MERGED manifest — every library folded in, not
# just ours. (The exact intermediate path varies by Android Gradle Plugin version.)
$ manifest=app/build/intermediates/merged_manifests/release/AndroidManifest.xml
$ test -f "$manifest" || { echo "no merged manifest at $manifest — assemble the release build first" >&2; exit 1; }
$ if grep -Eq 'CALL_PHONE|SEND_SMS|WRITE_CONTACTS' "$manifest"; then
      echo 'blocked: sensitive permission in release manifest' >&2; exit 1
  fi
$        # (file present AND no match → the if-branch is skipped → exit 0 → the build stays green)

The claim this proves: run on the release build's merged manifest — the full permission set after every dependency's manifest is folded in, not just what this feature's own file adds — the audit finds none of the three permissions the naive Call/Text/Save would have needed. A match trips the exit 1 and fails the build; the empty result here is the pass. Run it against your own build to check the same thing.

iOS has no manifest, but it has the same kind of tell — a usage-description key in the built Info.plist. For the contact-action row, the one that matters is NSContactsUsageDescription: the OS requires it before an app can even request Contacts access, so declaring it is the app announcing it may reach for the contact store — something the "hand the OS a vCard" path never has to.

# The iOS half of the same guard, one build over: FAIL if the built app's Info.plist
# declares the Contacts usage string — the key the OS requires before an app can
# prompt for Contacts access, which the vCard hand-off never needs.
$ plist="$BUILT_PRODUCTS_DIR/$WRAPPER_NAME/Info.plist"
$ test -f "$plist" || { echo "no built Info.plist at $plist — build the app first" >&2; exit 1; }
$ if /usr/libexec/PlistBuddy -c 'Print :NSContactsUsageDescription' "$plist" 2>/dev/null; then
      echo 'blocked: app declares Contacts access it should not need' >&2; exit 1
  fi
$        # (file present, key absent → PlistBuddy errors, the if is skipped → exit 0 → green)

The claim this proves — and only this: the built app declares no NSContactsUsageDescription, so it can show no Contacts authorization prompt. It does not prove no line of code references the store — a stale key or another feature could — which is exactly why the vCard hand-off is also pinned by the code-level test below, not by the plist alone. What it does catch is the most likely way this regresses: someone builds a store-backed editor and adds the NSContactsUsageDescription the store needs to work — the key reappears here and the build goes red before it ships. (A developer who adds store access without the key doesn't slip past this — they get a runtime crash instead; catching that is the code-level guard's job, below.)

Those empty outputs prove what shipped once. Keeping them empty is the tests' job — and the same guard expressed as a unit test catches the wrong intent before it ever reaches a build:

// Illustrative of the real guard tests — identifiers renamed, shape and asserts unchanged.

@Test fun call_button_opens_the_dialer_and_never_places_the_call() {
    val intent = contactActions.callIntent(number)
    assertEquals(Intent.ACTION_DIAL, intent.action)      // opens the dialer…
    assertNotEquals(Intent.ACTION_CALL, intent.action)   // …never places the call itself
    assertEquals("tel", intent.data?.scheme)             // and only ever hands it a number
}

// A build-verification test, not a mock: it parses the MERGED manifest off the
// release build, so it also catches a permission a dependency drags in.
@Test fun merged_manifest_declares_no_sensitive_contact_permissions() {
    val declared = mergedManifestPermissions()           // read from the built release manifest
    assertThat(declared).containsNoneOf(
        "android.permission.CALL_PHONE",
        "android.permission.SEND_SMS",
        "android.permission.WRITE_CONTACTS",
    )
}

The claim this proves: the "none of these permissions" promise is enforced by the build, not by anyone's memory. Swap the dialer intent for a direct call, or add SEND_SMS so a "just text it for them" shortcut stops throwing at runtime, and a guard test fails before the change reaches review — the store never gets the chance to notice first.

The iOS half of the suite carries the same invariant in its own shape — and it reaches something the Info.plist audit can't: whether the save flow actually exercises the contact store it depends on. The type takes that store as an injected dependency and constructs none of its own, so a spy on the injected store sees every store access there is.

// Illustrative of the real iOS guard tests — identifiers renamed, shape and asserts unchanged.

func test_callButton_buildsATelURL_andNeverDialsDirectly() {
    let url = contactActions.callURL(for: number)
    XCTAssertEqual(url.scheme, "tel")   // hands the number to the phone app…
    // …the app then opens the URL and lets iOS post its own "Call?" confirmation.
    // There is no direct-dial API it reaches for, because it holds no capability to.
}

// ContactActions constructs no CNContactStore of its own — the only one it can reach is
// the injected dependency below, so trapping that store covers every store access there is.
func test_saveButton_handsOffAVCard_andNeverTouchesTheContactStore() {
    let store = TrapContactStore()          // any access to this store fails the test
    let sut = ContactActions(store: store)
    let share = sut.saveToContactsActivity(number)
    XCTAssertTrue(share.contains(vCardPayload))   // shares a vCard the user imports…
    XCTAssertFalse(store.wasAccessed)             // …and never reads or writes the store
}

The claim this proves: the call helper only ever builds a tel: URL, and the save flow never touches the contact store it depends on — and since the type constructs no store of its own, that injected store is every store it could reach. Wire up a store-backed editor and wasAccessed trips; the vCard invariant is enforced by the test, not by a reviewer remembering it.

Matching platforms means matching intent, not buttons

One deliberate difference on this screen is worth naming, because it looks like a gap and isn't: on iOS we drop the standalone text button. That's a product call, not a platform limit — the surrounding design already exposes the primary message-composition flow there, so a second affordance would be clutter, not parity. Making iOS first-class was never about cloning Android's button row onto it pixel for pixel; it was about giving each platform the permission-free path that fits how it works — a tel: confirmation here, an ACTION_DIAL dialer there, a vCard hand-off where Android hands off an insert intent. Matching two platforms means matching what each does for the user, which is a longer story told here.

The question to ask of any contact action

Whether you're building this row or you've inherited one full of permissions nobody can quite account for, one question sorts it, and it's the same on both platforms: for a sensitive action — placing a call, sending an SMS, writing to contacts — does the app do it, or does it ask the OS to? Do it directly and you are holding the permission, the runtime prompt, and a privacy disclosure to make — and, for Android SMS, standing that a store can revoke. Ask, and you hold none of them, and the difference costs the user only an OS-owned confirmation or composition step they were going to move through anyway.

An inherited pile of permissions that don't match what the app visibly does is one of the more common things worth untangling in a rescue, on either platform, and it usually starts right here — with a permission diff that should have been empty, and wasn't.


Ramiz Raja is a senior mobile developer and technical lead with 12+ years shipping and rescuing production apps on Android, iOS, and Flutter. More at codebyramiz.com.