Test and release
How to prove in-app calls work before you ship — the test plan, the tools, what the App Store and Google Play ask for, and a go-live checklist.
Before you test
- The microphone first. Ask for it before the first call — during onboarding, say — on both platforms, so that a call answered on the lock screen has it.
- Real phones. CallKit and VoIP pushes do not run in the iOS Simulator. An Android emulator can ring, but the lock screen, Bluetooth and battery savers need a real phone — ideally two, from different manufacturers.
- The right push host. A build run from Xcode gets a sandbox VoIP token, for
api.sandbox.push.apple.com; TestFlight and App Store builds useapi.push.apple.com. - Debug logs. On iOS and Android,
GraineConfig(debug: true)logs the call's main steps — in Xcode's console under theai.graine.inappsubsystem, in logcat under the tagGraine. Warnings and errors are logged always; audio, messages and variable values never. In React Native and Flutter, setdebug: truein the calls options. - Call History. Each test call appears in the dashboard under the customer's
name from
identify(), with its transcript and outcome.
The test plan
Run every row on iOS and on Android, with the outcome your server receives checked against the last column.
| # | Do this | Expect | Outcomes |
|---|---|---|---|
| 1 | Tap your call button | The call screen opens and the agent speaks first, using your reason | ended (local) when you hang up |
| 2 | Ring with your app open | iOS: the system's incoming call, as a banner. Android: the call screen opens straight away | answered, then ended |
| 3 | Ring with your app in the background | iOS: the system's incoming call. Android: a call notification with Answer and Decline | answered, then ended |
| 4 | Ring with your app swiped away | It still rings: iOS relaunches the app for the push, FCM wakes it | answered, then ended |
| 5 | Ring a locked phone | iOS: the full-screen incoming call. Android: the full-screen ring, or a heads-up on Android 14+ without the full-screen permission | answered, then ended |
| 6 | Answer on the lock screen, lock again, open another app | The call goes on; on Android, the ongoing notification has Hang up | |
| 7 | Decline | The ring stops | declined |
| 8 | Let it ring out | It stops after the ring timeout | missed |
| 9 | Ring during a call | The call goes on, untouched | missed for the new ring |
| 10 | Let the agent end the call | The call screen closes | ended (remote) |
| 11 | Use a Bluetooth headset, then switch it off mid-call | The call follows it, as a phone call does, and comes back to the phone when it goes | |
| 12 | Turn on airplane mode mid-call | The call ends; it does not reconnect | ended (failed) |
| 13 | Deny the microphone, then place a call | The call does not connect. Android asks on the call screen first | failed |
| 14 | Deny the microphone when it is asked for after Answer (or deny it beforehand) | The call does not connect | answered, then failed |
| 15 | Android 13+: deny notifications, then ring with your app closed | Nothing rings | missed |
| 16 | Receive a phone call mid-call, then answer it | iOS: the customer can end the agent's call and take the phone call. Android: the agent's call goes on hold while the phone rings, and ends when the customer answers | ended (local) |
| 17 | Ring a device after reinstalling the app | APNs answers 410, FCM UNREGISTERED, and your server forgets the token |
With WebRTC in the build (Calls over WebRTC), run rows 1, 2, 5 and 12 again, then these:
| # | Do this | Expect | Outcomes |
|---|---|---|---|
| 18 | Tap your call button, and answer a ring | Both connect as fast as before, the greeting not clipped; the call's transport is webrtc | As rows 1 and 2 |
| 19 | Set the agent's In-app call audio to Standard, restart the app, place a call | It connects; transport is websocket (Android: transportFallbackReason is dashboard). Set it back | |
| 20 | Place a call on a network that blocks WebRTC (some office Wi-Fi) | It still connects, on the WebSocket, with no second ring; transportFallbackReason says why (often ice_timeout) | As row 1 |
| 21 | Mid-call over WebRTC, walk out of Wi-Fi onto mobile data | The call holds, or recovers within a few seconds; a network that stays down ends it | ended (failed) if it ends |
| 22 | Android: a release build with R8 | Calls still use WebRTC |
In React Native on Android with callScreen: "app", rows 13 and 14 differ: a
refused microphone places and answers nothing, so there is no outcome —
startCall returns null, the call's error says why, and a ring keeps
ringing until it is declined or missed.
To send the pushes, use Send a test push. When something does not ring or connect, start from Troubleshooting.
The App Store
Every capability, background mode and manifest entry calls need, per platform, is on Permissions.
- Capabilities. Push Notifications, and Background Modes with Audio, AirPlay, and Picture in Picture and Voice over IP. Calls do not work without both background modes.
- Review notes. Say that the app offers voice calls with your assistant, and give the reviewer a way to place one: a test account and where the call button is. Describe how your agent calls customers, since the reviewer cannot receive your server's 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 for the system APIs it uses (it does no tracking), in the Swift package and in the Flutter and React Native pods alike; what your app collects — the call's audio among it — goes in your App Privacy details. - China. Apple does not allow CallKit in apps on the App Store in China: leave calls off there.
Google Play
- Foreground service declaration. Apps targeting Android 14 or later declare
each foreground service type they use in Play Console (App content →
Foreground service permissions). The SDK's call service is of 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 a call doing so. - Full-screen intent. From Android 14, Google Play grants
USE_FULL_SCREEN_INTENTby default only to apps whose main purpose is calling or alarms, and asks the others for a declaration in Play Console. Without it, calls still ring, as a heads-up notification.calls.callPermissions.fullScreentells your app which it has, andcalls.openCallSettings(GraineCallPermission.FULL_SCREEN)opens the setting for the customer. - Data safety. Declare the audio your app sends for the conversation.
- Notifications and microphone. Ask during onboarding with
requestCallPermissions()(the microphone, andPOST_NOTIFICATIONSon Android 13+), so the first ring is not answered into a permission prompt. - The setup check. Before a release, read
callSetupProblemson a test phone (or the log atconfigure): it should be empty, or name only what you chose (hosted_ringing_offwhen your own server sends the pushes). - An app that does not use calls can remove the call permissions and service from its manifest — see Android → Calls.
React Native and Flutter releases
Calls are native code. Adding the calls package, or upgrading it, needs a new
store build: an over-the-air update cannot deliver it. So does turning WebRTC on:
the build switch (GRAINE_WEBRTC) adds a native library.
Go-live checklist
The agent
- The app's publishable key is in the build, and
app://is in the agent's Allowed domains. - The prompt handles both directions with
{who_called}and{call_reason}, and reads well when a call has no reason.
iOS
- Push Notifications, and Background Modes with Audio and Voice over IP.
-
NSMicrophoneUsageDescriptioninInfo.plist. -
enableVoIPPushesruns indidFinishLaunching, at every launch. - The APNs auth key is on your server, which uses the production host for TestFlight and App Store builds.
Android
- Firebase is in the app (
google-services.json, its Gradle plugin, the messaging library): the SDK's own messaging service does the rest. An app with its own messaging service callshandlePushbefore anything else. - Notifications and the microphone are asked for during onboarding.
- Play Console declarations are made for the microphone foreground service and, if it applies to your app, the full-screen intent.
WebRTC (optional)
- iOS:
GraineInAppRTCatGraineInApp's version; Android:ai.graine:inapp-android-rtcat the SDK's version; React Native and Flutter: the build switch on for both platforms. - WebRTC (BSD 3-Clause) is listed in your app's open-source licences.
- The agent's In-app call audio is WebRTC (recommended), and rows 18–22 pass.
Your server
- Tokens are saved at every launch and deleted on
410andUNREGISTERED. - A ring timer turns unanswered calls into
missed, and outcome reports are safe to repeat. - Your calling policy is in place: consent, hours, how often.
Tested
- The test plan above, on at least one iPhone and two Android phones from different manufacturers.
- Debug logging is off in the release build.

