Permissions
Every OS permission, capability, background mode, manifest entry and store declaration each feature needs on React Native (with Expo), iOS, Android and Flutter — who asks and when, the words to use, what a refusal does, and what App Store and Google Play review look for.
One rule covers every SDK: the SDK never asks on its own at launch. It asks
for the microphone when a call starts without it; for calls, one call —
requestCallPermissions() — asks for everything the system's dialogs can still
ask for, at a moment your app chooses. What a feature needs at build time your
app declares (the calls packages' Android manifests merge it for you), and the
SDK's setup check says, in plain words, anything that
is still missing.
At a glance
| Feature | iOS | Android |
|---|---|---|
| Chat | Nothing | Nothing beyond INTERNET |
| Voice in the bar | NSMicrophoneUsageDescription | RECORD_AUDIO, MODIFY_AUDIO_SETTINGS |
| Calls the customer places | The microphone, plus Push Notifications and the Audio and Voice over IP background modes | The microphone and a microphone foreground service. Without notifications allowed the call still runs; its ongoing notification does not show |
| Calls that ring the customer | The above. No notification permission: VoIP pushes and CallKit do not need one | The above, plus POST_NOTIFICATIONS (Android 13+) granted by the customer, and the full-screen-intent permission (Android 14+) for a full-screen ring |
| Hosted ringing | Nothing more than calls that ring; the SDK registers the VoIP token itself | Nothing more than calls that ring; forward Firebase's token to the SDK |
| Bluetooth and headsets | Nothing | Nothing — routing a call needs no Bluetooth permission |
Who adds what, per SDK:
| Declarations | Asked at run time | |
|---|---|---|
React Native — @graineai/inapp-react-native | You add the microphone: RECORD_AUDIO and MODIFY_AUDIO_SETTINGS, NSMicrophoneUsageDescription. The package carries no manifest | Android: the SDK asks for the microphone when a call starts. iOS: the system asks on first use |
React Native — @graineai/inapp-react-native-calls | Its Android manifest merges everything a call needs. On iOS its Expo config plugin adds the capabilities and the usage string; without Expo you add them | The SDK asks for the microphone when a call starts; requestCallPermissions() asks for the microphone and notifications when you choose |
Android — ai.graine:inapp-android | The library's manifest merges everything, voice and calls | The SDK asks for the microphone when a call starts; calls.requestCallPermissions() asks for the microphone and notifications when you choose |
iOS — GraineInApp | You add the usage string and, for calls, the capabilities | The SDK asks for the microphone when a call starts, or at calls.requestCallPermissions() |
Flutter — graine_inapp | From 0.6.1 its Android manifest merges everything; on iOS you add the usage string and, for calls, the capabilities | For voice in the bar, your app asks, through requestMicrophone. For calls, the SDK asks when a call starts; requestCallPermissions() asks for the microphone and notifications when you choose |
Kotlin Multiplatform — ai.graine:inapp-kmp | As Android and iOS | As Android and iOS: Graine.calls.requestCallPermissions() |
How it works
Ask at onboarding and the SDK never needs to: it finds the microphone granted and asks nothing.
Voice in the bar
The microphone, on every platform.
| Declare | Who asks, and when | If it is refused | |
|---|---|---|---|
| React Native | RECORD_AUDIO, MODIFY_AUDIO_SETTINGS; NSMicrophoneUsageDescription | Android: the SDK, when a call starts — including the moment the engine is ready, for a launcher that is active with autoStart. iOS: the system, the first time the call uses the microphone | No call. onMicDenied("NotAllowedError"), api.micDenied, and GraineVoiceBar shows Needs microphone |
| Android | Nothing | The SDK, at voice.start(). Forward your activity's onRequestPermissionsResult to voice.onRequestPermissionsResult | No call. onMicDenied; the bar shows Needs microphone, and comes back by itself when the customer returns from Settings with it allowed |
| iOS | NSMicrophoneUsageDescription | The SDK, at voice.start() | No call. onMicDenied; the bar shows Needs microphone until the next call starts |
| Flutter | RECORD_AUDIO; NSMicrophoneUsageDescription | Your app, in requestMicrophone, which the SDK calls before every call. Left out, the SDK assumes you asked | requestMicrophone returned false: no call, onMicDenied("NotAllowedError"), micDenied |
NotAllowedErroris a refusal;NotFoundErroris a device with no usable microphone.- The SDK never asks twice by itself. A later call asks again only where the system allows: iOS shows its dialog once, then only Settings can change it; Android may show it again, until the customer has refused it for good.
- Nothing falls back. A refused microphone never moves the call to another transport.
- iPhone's ringer switch. React Native leaves the audio session to your
app: while a call is live set
playsInSilentModeIOS: trueandallowsRecordingIOS: true, or the agent is silent whenever the ringer is off. The native iOS SDK sets its own session.
Calls
Native calls — the customer calls the agent, or the agent's call rings the customer — need more, because they run with the screen off and ring on the lock screen.
In Xcode, the app target's Signing & Capabilities:
| Add | Why |
|---|---|
| Push Notifications | VoIP pushes reach the app |
| Background Modes → Audio, AirPlay, and Picture in Picture | The call's audio keeps going with the screen off |
| Background Modes → Voice over IP | iOS wakes a closed app for a VoIP push |
NSMicrophoneUsageDescription in Info.plist | The microphone |
NSCameraUsageDescription in Info.plist — with the WebRTC module | Nothing at run time: calls are audio only. App Store processing requires it because WebRTC contains camera code (ITMS-90683) |
Nothing else: no notification permission (VoIP pushes and CallKit need none), no Bluetooth usage description, no remote-notification background mode.
The microphone is asked by the SDK before a call the customer places is
placed. For a call that rings, the customer answers first: the SDK reports
answered, the system asks while the call connects, and a refusal — or no
answer within 60 seconds — ends it failed. Ask during onboarding with
calls.requestCallPermissions() so a call answered on the lock screen already
has it.
The setup check reads Info.plist and the build's signing at calls.configure
and names whichever of the four above is missing (microphone_usage_missing,
background_audio_missing, background_voip_missing,
push_entitlement_missing).
Asking, reading and opening Settings
Every SDK has the same four, in its own idiom:
| Ask (when you choose) | Read now | Open Settings | What is still missing | |
|---|---|---|---|---|
| iOS | await calls.requestCallPermissions() | calls.callPermissions | calls.openCallSettings(.microphone) | calls.callSetupProblems |
| Android | calls.requestCallPermissions(activity) { p -> } | calls.callPermissions | calls.openCallSettings(GraineCallPermission.FULL_SCREEN) | calls.callSetupProblems |
| React Native | await GraineCalls.requestCallPermissions() | await GraineCalls.callPermissions() | GraineCalls.openCallSettings("fullScreen") | await GraineCalls.callSetupProblems() |
| Flutter | await GraineCalls.instance.requestCallPermissions() | await GraineCalls.instance.callPermissions() | GraineCalls.instance.openCallSettings(GraineCallPermission.fullScreen) | await GraineCalls.instance.callSetupProblems() |
| Kotlin Multiplatform | Graine.calls.requestCallPermissions() | Graine.calls.callPermissions | Graine.calls.openCallSettings(GraineCallPermission.FULL_SCREEN) | Graine.calls.callSetupProblems |
Three permissions, each with one of four statuses:
| Permission | iOS | Android |
|---|---|---|
microphone | Asked once; refused, only Settings can change it | Asked; refused for good, only Settings can change it |
notifications | notNeeded — CallKit rings without them | Android 13+ asks; before 13, on unless turned off in Settings |
fullScreen | notNeeded — CallKit draws its own screen | Android 14+: Settings only; before, granted with the app |
granted; denied — not yet, asking may still change it; blocked — only
Settings can change it; notNeeded — this phone needs no such permission.
allGranted is true when each one is granted or not needed. Asking never
leaves your app: the full-screen ring, which has no dialog, is opened with
openCallSettings. Read the permissions again when your app returns to the
foreground — the customer may have changed Settings.
What is still missing
At calls.configure the SDK checks the app's setup for calls and logs each
problem once, as a warning with the exact fix — in Xcode's console (subsystem
ai.graine.inapp) and logcat, and also in Metro's console (React Native) or the
flutter run console in a development build. callSetupProblems returns the
same list, each a stable code and a message:
| Code | Platform | What it means, and the fix |
|---|---|---|
microphone_usage_missing | iOS | No NSMicrophoneUsageDescription: iOS closes the app when a call asks for the microphone. Add it |
background_audio_missing | iOS | No audio background mode: a call goes silent when the screen locks |
background_voip_missing | iOS | No voip background mode: iOS does not wake the app for a ring |
push_entitlement_missing | iOS | Signed without Push Notifications — read from development and ad hoc builds; App Store builds embed nothing to read |
voip_pushes_not_enabled | iOS | enableVoIPPushes not called 15 seconds after configure: calls can't ring |
no_voip_token | iOS | No VoIP token 15 seconds after enableVoIPPushes (never reported on the simulator, which gets none) |
manifest_entry_missing | Android | The merged manifest lost a call entry — look for tools:node="remove" |
no_push_token | Android | No Firebase token reached the SDK 15 seconds after configure: Firebase is missing or not set up in the app, its automatic token creation is off, or Firebase could not give one — the message says which. An app with its own messaging service hands tokens over (onNewToken, registerPushToken) |
notifications_not_allowed | Android | A call can't ring while the app is closed: ask, or offer Settings if they were turned off |
full_screen_not_allowed | Android | A locked phone rings with a heads-up notification: offer openCallSettings(fullScreen) |
microphone_blocked | Both | Only Settings can let calls hear the customer: offer openCallSettings(microphone) |
hosted_ringing_off | Both | Graine can't ring this phone: no push credentials uploaded for this platform (unless your own server sends the pushes) |
A problem is never an exception: calls still work wherever they can.
Not using calls
On Android 14 and later, Google Play asks apps that declare
USE_FULL_SCREEN_INTENT or FOREGROUND_SERVICE_MICROPHONE to explain why. An app
that never places a call can remove them, and the call's service, in its own
manifest — see Android → Calls.
POST_NOTIFICATIONS and FOREGROUND_SERVICE can go too; an app that keeps
calls must keep all of them.
Hosted ringing
No new permission. A call Graine rings needs exactly what any call that rings
needs — the iOS capabilities, and on Android the notification and full-screen
permissions above. What changes is who holds the tokens: on iOS,
enableVoIPPushes at launch registers the VoIP token with Graine; on Android,
with Firebase in the app, the SDK registers Firebase's token itself (an app with
its own messaging service hands it over in onNewToken).
Hosted ringing
Bluetooth and headsets
No permission on any platform. A call goes to a Bluetooth headset or car kit when
one is connected, else a wired headset, else the earpiece, and follows when a
headset comes or goes. On Android that routing runs on MODIFY_AUDIO_SETTINGS,
which every SDK already declares — no BLUETOOTH_CONNECT. On iOS the call's
audio session allows Bluetooth hands-free, with no Bluetooth usage description.
Voice in the bar follows the phone's current audio route.
Asking at the right moment
Ask during onboarding, at a moment that explains itself ("Talk to Asha"), and
before the first call — then the SDK never prompts in the middle of one. With
calls, requestCallPermissions() is that one line on every SDK (the table
above); the examples below ask without the calls API, for voice in the bar.
To show iOS's prompt earlier than the first call, use the permission library your app already has.
The words to use
| Where | Suggested text |
|---|---|
iOS — NSMicrophoneUsageDescription | Used to talk to the in-app assistant. — or, with calls, Lets you talk to Asha in the app and on calls. Say what the customer gets, not what the app does |
| Android — the microphone, before you ask | Allow the microphone so you can talk to the assistant. — the words React Native's SDK uses when it asks |
| Android — notifications, before you ask | Allow notifications so calls from Asha can ring. |
| Android — full screen, before you open the setting | Allow full-screen notifications so a call from Asha fills the screen when your phone is locked. |
| After a refusal | The microphone is off for Acme. Turn it on in Settings to talk to Asha. — with a button to Settings |
Store review
The App Store
- Voice over IP is for calls only. iOS requires every VoIP push to be reported to CallKit at once and stops delivering them to apps that do not. The SDK reports every one; send VoIP pushes only for calls.
- Review notes. Say the app offers voice calls with your assistant, give the reviewer a test account and where the call button is, and describe how your agent's calls ring — the reviewer cannot receive your push.
- Privacy. The microphone purpose string is required — and, with the WebRTC module, the camera one,
NSCameraUsageDescription— WebRTC contains camera code, and App Store processing refuses a build that references the camera without it (ITMS-90683: the build never reaches TestFlight). Calls are audio only; say so, for example Calls are audio only. The camera is never turned on. The SDK ships a privacy manifest declaring the required-reason APIs it calls — system boot time, for timing, andUserDefaults— and no tracking. What your app collects through the agent, the customer's voice included, goes in your App Privacy details. - China. Apple does not allow CallKit in apps on the App Store in China: leave calls off there.
- Self-contained apps (guideline 2.5.2). The SDK downloads no code. What the
agent sends while it runs is content and settings — its words, its voice, the
cards and choices it shows, and your Garage design — drawn by code already in
your app. Apple does reject builds that carry development tools able to load
code from a URL: leave them out of store builds. With Expo, remove the
development client (
expo-dev-client) from the build you submit — it compiles into release builds even though it only runs in development. Over-the-air JavaScript updates are your app's choice; keep them to fixes within the features Apple reviewed. - A note for the reviewer. For example: "The app includes an AI assistant you can chat and talk with. Its replies, its voice and the appearance of the chat and call screens are delivered as content and settings from our server and drawn by the app's own code; no executable code is downloaded. Calls use CallKit and VoIP push only for real incoming calls."
Google Play
- Foreground service. Apps targeting Android 14 or later declare each
foreground service type in Play Console (App content → Foreground service
permissions). The call's service is type
microphone: describe it as two-way voice calls with your assistant that keep going when the screen locks, and link a short video of one. - Full-screen intent. Granted by default only to apps whose main purpose is calling or alarms; other apps declare it in Play Console, or go without — the ring is then a heads-up notification.
- Data safety. Declare the audio your app sends for the conversation.
- Executable code. Google Play forbids apps from downloading code (dex files or native libraries) outside Play. The SDK downloads none; the agent's replies, components and your design arrive as content.
Troubleshooting
| What you see | Why |
|---|---|
| Anything below, with calls | Read callSetupProblems (or the log at configure): it names what is missing and the fix |
| Needs microphone, and no dialog ever appeared | The permission is not declared — RECORD_AUDIO in the manifest (React Native and Flutter), or the usage string on iOS — or the customer refused it before |
| iOS: the app closes as a call starts, or the microphone is never offered | NSMicrophoneUsageDescription is missing: iOS stops an app that asks for the microphone without one |
| Android: a granted call stays on Connecting… | Your activity does not forward onRequestPermissionsResult to the SDK |
| Flutter: the microphone is never granted | requestMicrophone is not set, or the manifest or Info.plist entry is missing |
| Android 13+: calls never ring with the app closed | Notifications are not allowed. Ask during onboarding |
| Android 14+: a heads-up instead of a full-screen ring | No full-screen-intent permission. It still rings; offer the setting with openCallSettings(fullScreen) |
| iOS: a call answered on the lock screen is silent | Background Modes must include Audio and Voice over IP |

