Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
da61975
refactor(android): remove dead answered param from releaseIncomingCal…
SERDUN May 11, 2026
57b914e
fix(android): add CATEGORY_CALL to silent FGS notification to prevent…
SERDUN May 11, 2026
6a42f35
chore(android): add startForeground marker logs to IncomingCallHandle…
SERDUN May 11, 2026
f68246f
feat(android): native file logging for callkeep services (#286)
SERDUN May 11, 2026
2d2cd44
chore: remove deprecated logs delegate API (#294)
SERDUN May 11, 2026
f577145
chore: back-merge main into develop (converge histories, keep develop…
SERDUN May 22, 2026
0e0cb26
build: align develop version with released main (1.1.0+0)
SERDUN May 22, 2026
cdddcaa
feat(android): WebtritCallkeep.attachToEngine — host callkeep on an e…
SERDUN May 26, 2026
1191d28
fix: centralize pending-callId drain in InProcessCallkeepCore (WT-153…
SERDUN May 26, 2026
474cc9a
feat(ios): add Swift Package Manager support to webtrit_callkeep_ios …
SERDUN May 27, 2026
19445c6
feat: show hungup on minimized active call push (#302)
digiboridev May 28, 2026
4598cb0
ci: enforce callkeep tag == version on tag push (#304)
SERDUN Jun 5, 2026
e5fd593
ci: fix cspell findings in incoming-call-handling doc (#306)
SERDUN Jun 5, 2026
ee12131
ci: auto-tag main with umbrella version on release merge (#307)
SERDUN Jun 5, 2026
f24d928
ci: read version with awk to keep error path reachable (#308)
SERDUN Jun 5, 2026
d004881
fix(android): stop surfacing literal "undefined" as the caller number…
SERDUN Jun 8, 2026
ec27a78
feat(android): expose call delivery mode (Telecom vs standalone) to F…
SERDUN Jun 9, 2026
53a080b
fix(android): replace unsafe !! assertions across critical call paths…
SERDUN Jun 12, 2026
462d543
chore(android): upgrade Gradle to 9.5.1 and fix unit tests on JDK 26 …
SERDUN Jun 12, 2026
cccd397
fix(android): serialize OutgoingFailureType by name instead of ordina…
SERDUN Jun 12, 2026
f002a86
chore(tests): actualize integration tests — helpers, delivery mode, c…
SERDUN Jun 13, 2026
c383539
test(android): add fast-fail timeout to concurrent spam integration t…
SERDUN Jun 13, 2026
467c553
fix(android): guard pendingIncomingCallbacks slot against concurrent …
SERDUN Jun 14, 2026
89500d5
chore(ci): run Firebase integration tests on merge and release only (…
SERDUN Jun 14, 2026
651e42a
chore: bump pub dependencies to latest compatible versions (#318)
SERDUN Jun 14, 2026
57dbcd9
fix: parse service intents into typed commands to avoid metadata cras…
SERDUN Jun 14, 2026
b5cbc73
ci: bump Flutter to 3.44.1 and exclude generated files from format ga…
SERDUN Jun 14, 2026
eeefe5e
fix(android): distinguish incoming video calls in notification (#321)
SERDUN Jun 15, 2026
cafb7ab
fix(android): keep ringtone for a still-ringing call when another end…
SERDUN Jun 15, 2026
7470b4a
fix(android): report incoming calls via TelecomManager to survive pro…
SERDUN Jun 15, 2026
a22ab32
refactor(android): rename signaling-registered guard to reported-inco…
SERDUN Jun 17, 2026
a3ea469
docs(android): document reportedIncoming guard in CallkeepCore (#326)
SERDUN Jun 17, 2026
6ce3e34
refactor(android): emit DidPushIncomingCall at connection creation, n…
SERDUN Jun 17, 2026
43262d8
refactor(android): make onStateChanged the source of truth for shadow…
SERDUN Jun 17, 2026
85112c3
refactor(android): rename SyncConnectionState to ReplayConnectionStat…
SERDUN Jun 17, 2026
c04957b
fix(android): replay connection state on delegate attach, not on serv…
SERDUN Jun 17, 2026
200f0e1
refactor(android): drop redundant tracker guard before audio re-sync …
SERDUN Jun 17, 2026
a6d0462
refactor(android): rename SyncAudioState to ReplayAudioState (#332)
SERDUN Jun 17, 2026
2fc3f25
refactor(android): rename DidPushIncomingCall event to IncomingConnec…
SERDUN Jun 17, 2026
6b2ef2e
docs(android): document incoming-call delivery paths + mark SMS trigg…
SERDUN Jun 17, 2026
1185e16
fix(android): adopt ringing incoming call on Flutter delegate attach …
SERDUN Jun 17, 2026
b141c67
fix: suppress ghost incoming when call terminated before connection-s…
SERDUN Jun 18, 2026
d0fce70
chore: back-merge main into develop (converge histories, keep develop…
SERDUN Jun 21, 2026
eb548fb
build: align develop version with released main (1.2.0+0)
SERDUN Jun 21, 2026
8647f28
fix: open app UI and swap notification when standalone call is answer…
SERDUN Jul 2, 2026
cf42af3
fix: audio mode set for miui 12 legacy routing (WT-1489) (#339)
digiboridev Jul 3, 2026
d664409
fix: promote standalone call service with phone-call type instead of …
SERDUN Jul 3, 2026
02cd4ce
feat(android): background-activity-start permission for lock-screen c…
SERDUN Jul 6, 2026
d85d004
chore: back-merge main into develop (converge histories, keep develop…
SERDUN Jul 6, 2026
adba61b
build: align develop version with released main (1.3.0+0)
SERDUN Jul 6, 2026
159f08d
feat(ios): play call-waiting tone on second incoming call during acti…
SERDUN Jul 6, 2026
bbf48ce
build: create a new release version 1.3.1
SERDUN Jul 6, 2026
e49efe6
build: bump version build iteration to 1.3.1+1 (#351)
SERDUN Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions docs/ios-call-waiting-tone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# iOS call-waiting tone: design notes

## Purpose

When a second incoming call arrives while another call is already active, iOS gives the user no audible
indication: CallKit does not auto-play a call-waiting tone for VoIP calls (that beep on cellular calls is
a carrier feature), and it also suppresses the regular ringtone for the second call. Android handles the
same scenario natively in the connection service - a soft tone instead of the full ringtone. This document
explains how the iOS side works and, more importantly, why it is built this way, because most of the
"obvious" alternatives fail in non-obvious ways.

Implementation: `CallWaitingTonePlayer` (playback) plus the `CXCallObserverDelegate` sync in
`WebtritCallkeepPlugin` (detection). Everything is native to this plugin: no Dart API, no app involvement,
no dependency on any other plugin.

## The core problem: audio playback during a live call

During a VoIP call the audio session runs `playAndRecord` with mode `voiceChat`, and the call audio flows
through the voice-processing I/O unit (VPIO - the one doing echo cancellation and noise suppression).

Check warning on line 19 in docs/ios-call-waiting-tone.md

View workflow job for this annotation

GitHub Actions / spell-check / build

Unknown word (VPIO)
Apple treats every audio stream that is not rendered through the voice-processing unit - including streams
from the SAME app - as "other audio" (WWDC23 session 10235, "What's new in voice processing").

Check warning on line 21 in docs/ios-call-waiting-tone.md

View workflow job for this annotation

GitHub Actions / spell-check / build

Unknown word (WWDC)

Two distinct mechanisms then affect that "other audio":

1. **Documented ducking** (`AVAudioVoiceProcessingOtherAudioDuckingConfiguration`, iOS 17+). Mild by
itself; the WebRTC stack used with this plugin already configures `duckingLevel = .min`. This is NOT
what makes other audio inaudible.
2. **An undocumented, long-standing behavior** (Apple developer forums thread 721535, reproduced across
iOS 13-26): audio sources STARTED AFTER the voice-processing unit is running play near-silent, as if
not routed to the output. This affects `AVAudioPlayer`, `AVPlayer` and additional `AVAudioEngine`
instances alike. Two things counter it:
- sources set up BEFORE voice processing starts keep full volume;
- re-issuing `setCategory` with the current values (an idempotent no-op configuration-wise) restores
full volume for late-started sources without changing the route or the session mode.

`CallWaitingTonePlayer` is therefore a plain `AVAudioPlayer` (an in-memory synthesized WAV, looped) with
both mitigations applied:

- **Pre-warm ordering.** The player is created and `prepareToPlay`-ed inside the native
`CXProviderDelegate provider:didActivateAudioSession:` callback. This callback is the earliest audio
moment of a call and it reaches the plugin natively BEFORE the app-side (Dart) roundtrip that starts the
WebRTC voice-processing engine - so the playback source predates VP by construction. Keep it that way:
moving player creation to first-play would land in the near-silent case above.
- **Category re-assert.** After each actual playback start, the current session category and options are
re-asserted. It is guarded to run only on a real stop-to-play transition (not on every call-state
event), because each `setCategory` is a synchronous audio-server call.

The tone is heard locally only: everything the device plays is part of the voice-processing
echo-cancellation reference and is subtracted from the microphone signal, so the remote side does not
hear the beep.

## Rejected alternatives (and why)

| Approach | Why not |
|---|---|
| `AVAudioPlayer` without the two mitigations | Near-silent during a live call (behavior 2 above). |
| `AudioServicesPlaySystemSound` | System (UI) sounds are hard-disabled during calls by a separate mechanism; no workaround. |
| Custom `CXProviderConfiguration.ringtoneSound` | The second call's ringtone is suppressed during an active call; and if it did play, it would be ringer-volume - the exact problem the soft tone solves. |
| A player node inside WebRTC's own `AVAudioEngine` | Works and is fully duck-proof (it IS the call audio path), but the engine is owned by the WebRTC plugin and is recreated per call and on every route change - hosting the tone there either puts telephony logic into a transport plugin or requires fragile cross-plugin lifecycle contracts. Only worth revisiting if the mitigations above ever stop working. |
| A separate app-owned `AVAudioEngine` | Same "other audio" class as `AVAudioPlayer` (no ducking advantage), plus engine lifecycle/config-change handling for nothing. |
| Ducking configuration tweaks | `duckingLevel` is already `.min` in this stack; `enableAdvancedDucking` ducks MORE while speech is present. |
| `overrideOutputAudioPort(.speaker)` (also restores volume) | Moves the whole call from the earpiece to the speaker - unacceptable. |

## Detection

The plugin observes `CXCallObserver` and plays the tone while at least one call is connected (or held)
and at least one incoming call is ringing, stopping as soon as that state ends. Design points:

- **Native, not app-driven.** Mirrors the Android connection-service logic, works even when the Dart side
is busy or not running, and requires no API surface.
- **Own calls only (default).** `CXCallObserver` reports every CallKit call on the device - cellular,
other VoIP apps - so unfiltered detection would beep on top of foreign calls (or double up with the
carrier's own call-waiting tone). The plugin tracks the UUIDs of calls it reported/started and counts
only those. `CallkeepIOSOptions.callWaitingToneOwnCallsOnly = false` widens detection to all calls if a
product ever wants the beep while the user is on a cellular call.
- **Answer suppression.** A `CXCall` keeps looking "ringing" until the answer action is fulfilled, which
includes an app roundtrip and SIP signaling (seconds on a slow network). The UUID is excluded from the
ringing set the moment the user accepts, so the beep does not bleed into the answered conversation.
- **No playback decisions from the provider queue.** `didActivateAudioSession` arrives on the provider's
private queue; acting on cached state there can resume a tone that the (main-queue) call-state sync has
already ended. The callback only pre-warms; play/stop decisions are made exclusively by the sync running
on the observer's queue.
- **Scope boundary.** An outgoing call that is still dialing has `hasConnected == NO`, so a second
incoming call during it produces no tone - intentionally the same as Android, where the connection
service plays the full ringtone in that case.

## Tone pattern

A single 440 Hz, 300 ms beep on a 3-second cadence - matching what Android produces
(`ToneGenerator.TONE_SUP_CALL_WAITING` is a 440 Hz / 300 ms beep, re-fired by the connection service
every 3 s), so both platforms sound identical. The WAV is synthesized in memory (`initWithData:`); do not
switch to a temp-file URL - `AVAudioPlayer initWithContentsOfURL:` has been observed returning nil for
freshly written temp WAVs on device.

## Maintenance gotchas

- Native ObjC changes require a full rebuild; Flutter hot reload/restart swaps only Dart. When a change
"has no effect", check the built product first (e.g. `strings <app>/Runner.debug.dylib | grep <marker>`).
- `NSLog` from the plugin is not visible in `flutter run` output - use Console.app. The detection sync
logs `[CallWaitingTone] sync: ...` in DEBUG builds.
- The pre-warm-before-voice-processing ordering is the load-bearing invariant of the playback path. Any
refactor that delays player creation past the start of the call's audio engine reintroduces the
near-silence failure, and nothing will crash or log - it will just be quiet.
77 changes: 73 additions & 4 deletions webtrit_callkeep/lib/src/webtrit_callkeep_permissions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,73 @@ class WebtritCallkeepPermissions {
return platform.openFullScreenIntentSettings();
}

/// Status of the OEM "display pop-up windows while running in background"
/// capability (MIUI/HyperOS), which gates showing the incoming-call UI over
/// the lock screen.
///
/// On non-Android platforms and web, returns
/// [CallkeepSpecialPermissionStatus.granted] (the capability does not apply).
Future<CallkeepSpecialPermissionStatus> getBackgroundActivityStartPermissionStatus() {
if (kIsWeb) {
return Future.value(CallkeepSpecialPermissionStatus.granted);
}

if (!Platform.isAndroid) {
return Future.value(CallkeepSpecialPermissionStatus.granted);
}

return platform.getBackgroundActivityStartPermissionStatus();
}

/// Attempts to open the OEM permissions screen hosting the "display pop-up
/// windows while running in background" toggle.
///
/// On non-Android platforms and web, this call does nothing.
Future<void> openBackgroundActivityStartSettings() {
if (kIsWeb) {
return Future.value();
}

if (!Platform.isAndroid) {
return Future.value();
}

return platform.openBackgroundActivityStartSettings();
}

/// Status of the OEM "show on lock screen" capability (MIUI/HyperOS), which
/// gates showing the incoming-call UI over the lock screen.
///
/// On non-Android platforms and web, returns
/// [CallkeepSpecialPermissionStatus.granted] (the capability does not apply).
Future<CallkeepSpecialPermissionStatus> getShowWhenLockedPermissionStatus() {
if (kIsWeb) {
return Future.value(CallkeepSpecialPermissionStatus.granted);
}

if (!Platform.isAndroid) {
return Future.value(CallkeepSpecialPermissionStatus.granted);
}

return platform.getShowWhenLockedPermissionStatus();
}

/// Attempts to open the OEM permissions screen hosting the "show on lock
/// screen" toggle.
///
/// On non-Android platforms and web, this call does nothing.
Future<void> openShowWhenLockedSettings() {
if (kIsWeb) {
return Future.value();
}

if (!Platform.isAndroid) {
return Future.value();
}

return platform.openShowWhenLockedSettings();
}

/// Attempts to open the system settings screen for managing the app's permissions.
// TODO(Serdun): Add support for iOS.
Future<void> openSettings() {
Expand Down Expand Up @@ -129,10 +196,12 @@ extension CallkeepSpecialPermissionsExtension on CallkeepSpecialPermissions {
/// If the permission is [CallkeepSpecialPermissions.fullScreenIntent], it checks the full screen intent permission status.
/// Returns a [Future] that resolves to a [CallkeepSpecialPermissionStatus] indicating the status of the permission.
Future<CallkeepSpecialPermissionStatus> status() async {
if (this == CallkeepSpecialPermissions.fullScreenIntent) {
final callkeepPermissions = WebtritCallkeepPermissions();
return callkeepPermissions.getFullScreenIntentPermissionStatus();
final callkeepPermissions = WebtritCallkeepPermissions();
switch (this) {
case CallkeepSpecialPermissions.fullScreenIntent:
return callkeepPermissions.getFullScreenIntentPermissionStatus();
case CallkeepSpecialPermissions.backgroundActivityStart:
return callkeepPermissions.getBackgroundActivityStartPermissionStatus();
}
return CallkeepSpecialPermissionStatus.granted;
}
}
2 changes: 1 addition & 1 deletion webtrit_callkeep/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: webtrit_callkeep
description: Flutter WebTrit CallKeep plugin
version: 1.3.0+1
version: 1.3.1+1
publish_to: none

environment:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,30 @@ interface PHostBackgroundPushNotificationIsolateApi {
interface PHostPermissionsApi {
fun getFullScreenIntentPermissionStatus(callback: (Result<PSpecialPermissionStatusTypeEnum>) -> Unit)
fun openFullScreenIntentSettings(callback: (Result<Unit>) -> Unit)
/**
* Status of the OEM "display pop-up windows while running in background"
* capability (MIUI/HyperOS `OP_BACKGROUND_START_ACTIVITY`), which gates
* showing the incoming-call Activity over the lock screen. Best-effort:
* reports granted on devices where the capability does not apply.
*/
fun getBackgroundActivityStartPermissionStatus(callback: (Result<PSpecialPermissionStatusTypeEnum>) -> Unit)
/**
* Opens the OEM permissions screen that hosts the "display pop-up windows
* while running in background" toggle, with a fallback to app settings.
*/
fun openBackgroundActivityStartSettings(callback: (Result<Unit>) -> Unit)
/**
* Status of the OEM "display pop-up windows while running in background"
* MIUI/HyperOS `OP_SHOW_WHEN_LOCKED` capability, which gates showing the
* incoming-call Activity over the lock screen. Best-effort: reports
* granted on devices where the capability does not apply.
*/
fun getShowWhenLockedPermissionStatus(callback: (Result<PSpecialPermissionStatusTypeEnum>) -> Unit)
/**
* Opens the OEM permissions screen that hosts the "show on lock screen"
* toggle, with a fallback to app settings.
*/
fun openShowWhenLockedSettings(callback: (Result<Unit>) -> Unit)
fun openSettings(callback: (Result<Unit>) -> Unit)
fun getBatteryMode(callback: (Result<PCallkeepAndroidBatteryMode>) -> Unit)
/**
Expand Down Expand Up @@ -1507,6 +1531,76 @@ interface PHostPermissionsApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.webtrit_callkeep_android.PHostPermissionsApi.getBackgroundActivityStartPermissionStatus$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.getBackgroundActivityStartPermissionStatus{ result: Result<PSpecialPermissionStatusTypeEnum> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeneratedPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(GeneratedPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.webtrit_callkeep_android.PHostPermissionsApi.openBackgroundActivityStartSettings$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.openBackgroundActivityStartSettings{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeneratedPigeonUtils.wrapError(error))
} else {
reply.reply(GeneratedPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.webtrit_callkeep_android.PHostPermissionsApi.getShowWhenLockedPermissionStatus$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.getShowWhenLockedPermissionStatus{ result: Result<PSpecialPermissionStatusTypeEnum> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeneratedPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(GeneratedPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.webtrit_callkeep_android.PHostPermissionsApi.openShowWhenLockedSettings$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.openShowWhenLockedSettings{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeneratedPigeonUtils.wrapError(error))
} else {
reply.reply(GeneratedPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.webtrit_callkeep_android.PHostPermissionsApi.openSettings$separatedMessageChannelSuffix", codec)
if (api != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,63 @@ class PermissionsApi(
}
}

/**
* Reports the status of the OEM "display pop-up windows while running in
* background" capability (MIUI/HyperOS), which gates showing the incoming
* call UI over the lock screen. Best-effort; reports granted where the
* capability does not apply.
*/
override fun getBackgroundActivityStartPermissionStatus(callback: (Result<PSpecialPermissionStatusTypeEnum>) -> Unit) {
val status =
when (PermissionsHelper(context).isBackgroundActivityStartGranted()) {
true -> PSpecialPermissionStatusTypeEnum.GRANTED
false -> PSpecialPermissionStatusTypeEnum.DENIED
null -> PSpecialPermissionStatusTypeEnum.UNKNOWN
}
callback.invoke(Result.success(status))
}

/**
* Opens the OEM permissions screen hosting the "display pop-up windows while
* running in background" toggle, with a fallback to app settings.
*/
override fun openBackgroundActivityStartSettings(callback: (Result<Unit>) -> Unit) {
try {
PermissionsHelper(context).launchBackgroundActivityStartSettings()
callback.invoke(Result.success(Unit))
} catch (e: Exception) {
callback.invoke(Result.failure(e))
}
}

/**
* Reports the status of the OEM "show on lock screen" capability
* (MIUI/HyperOS), which gates showing the incoming call UI over the lock
* screen. Best-effort; reports granted where the capability does not apply.
*/
override fun getShowWhenLockedPermissionStatus(callback: (Result<PSpecialPermissionStatusTypeEnum>) -> Unit) {
val status =
when (PermissionsHelper(context).isShowWhenLockedGranted()) {
true -> PSpecialPermissionStatusTypeEnum.GRANTED
false -> PSpecialPermissionStatusTypeEnum.DENIED
null -> PSpecialPermissionStatusTypeEnum.UNKNOWN
}
callback.invoke(Result.success(status))
}

/**
* Opens the OEM permissions screen hosting the "show on lock screen"
* toggle, with a fallback to app settings.
*/
override fun openShowWhenLockedSettings(callback: (Result<Unit>) -> Unit) {
try {
PermissionsHelper(context).launchShowWhenLockedSettings()
callback.invoke(Result.success(Unit))
} catch (e: Exception) {
callback.invoke(Result.failure(e))
}
}

/**
* Attempts to open the common system settings screen
*/
Expand Down
Loading
Loading