Skip to content

release: 1.0.0#247

Draft
SERDUN wants to merge 65 commits intomainfrom
release/1.0.0
Draft

release: 1.0.0#247
SERDUN wants to merge 65 commits intomainfrom
release/1.0.0

Conversation

@SERDUN
Copy link
Copy Markdown
Member

@SERDUN SERDUN commented Apr 10, 2026

Release 1.0.0

SERDUN and others added 30 commits November 28, 2025 11:38
- Extend PHostApi with onDelegateSet() and wire new channel
- Implement ForegroundService.onDelegateSet to resync active connections
- Add forceUpdateAudioState() in PhoneConnection to push audio state to Flutter
The `log` method checked for list emptiness synchronously but accessed the element asynchronously via `Handler.post`. If the `isolateDelegates` list was cleared before the Handler executed, a `NoSuchElementException` occurred.

Replaced `.first()` with `.firstOrNull()?.` to safely handle cases where the list becomes empty during the thread switch context.
* feat: add comprehensive CallDiagnostics utility and failed-call reporting

* feat: wire diagnostics and runtime permission APIs to Flutter plugin

- Expose CallkeepDiagnostics via AndroidCallkeepUtils and new Dart
  CallkeepDiagnostics helper that calls getDiagnosticReport() on the
  platform interface.
- Add PHostDiagnosticsApi + DiagnosticsApi on Android that delegate to
  CallDiagnostics.gatherMap(context), and register/unregister it in
  WebtritCallkeepPlugin.
- Extend PHostPermissionsApi with generic requestPermissions() and
  checkPermissionsStatus() methods, including:
  - New PCallkeepPermission + PPermissionResult Pigeon types.
  - PSpecialPermissionStatusTypeEnum.unknown state.
  - Extensions to map between PCallkeepPermission, Android manifest
    permission strings, and high-level CallkeepPermission enums.
- Implement requestPermissions() and checkPermissionsStatus() in
  PermissionsApi using ActivityHolder and ContextCompat checks.
- Introduce CallkeepPermission and CallkeepSpecialPermissionStatus.unknown
  on the platform interface, plus conversion helpers.
- Implement the new diagnostics + permission APIs in
  WebtritCallkeepAndroid, returning strongly typed Maps to Flutter.
- Update generated Pigeon codecs and test stubs to support new enums,
  data classes, and message channels.

* feat: add callback-based permission handling with timeout

Refactor CallKeep Android permissions flow to use a single pending
callback and listen to real permission results from the Activity.
#113)

* refactor: improve ActivityWakelockManager logging and safety

* feat: add verbose logging support to Log helper

* fix: apply wakelock handling on call update events
…116)

* refactor: streamline PhoneConnection lifecycle and audio routing logic

- Rework KDoc and simplify state/metadata handling for readability
- Extract timeout handling into onTimeoutTriggered() and tidy ConnectionTimeout API/docs
- Refactor audio routing for pre/post API 34 with dedicated mappers and helpers
- Add endpoint switching helper with OutcomeReceiver wrapper for CallEndpoint changes
- Rename/change intent: applyVideoState(), updateModernAudioState(), determineLegacyRoute()
- Improve factories param naming/order and centralize safe terminateWithCause() logic

* refactor: clean up Log dispatcher and centralize delegate handling

- Add global log tag prefix for consistent Android logging
- Extract system logging into performSystemLog()
- Isolate delegate dispatch logic into dedicated helper methods
- Ensure delegate callbacks are always posted on the main thread
- Improve readability and separation of concerns in Log utility

* fix: log error when requested call endpoint is not found

* refactor: reuse main thread handler for log delegate dispatch

* refactor: rename availableCallEndpoints and cancel timeout on disconnect

* refactor: rename handleIncomingTimeout to handleConnectionTimeout

* refactor: improve ConnectionTimeout constants ordering and documentation

* refactor: reuse shared executor for audio endpoint changes

* chore: update terminateWithCause log message

* refactor: rename changeMuteState parameter for clarity

* refactor: make logging thread-safe and improve throwable handling

* fix: handle null audio device id when selecting call endpoint

* chore: remove unused imports and tidy import ordering
Explain why CallMetadata must not implement Parcelable when passed through ConnectionService/Telecom extras:
system_server may unmarshal bundles (e.g., ConnectionRequest.toString()), and a custom Parcelable can
trigger BadParcelableException/ClassNotFoundException due to missing app ClassLoader. Document the
safe approach: manual Bundle field serialization using primitive Android types.
* refactor: clean up Bundle extras handling for CallMetadata

- add Bundle.getLongOrNull/getCharOrNull helpers
- store audioDevices as ArrayList<Bundle> to avoid Telecom unmarshalling issues
- parse created/accepted times safely and ignore default DTMF char

* refactor: clarify CallMetadata merge semantics and null-safe bundle parsing

- Rename mergeWith() to updateFrom() to better reflect update semantics
- Document merge strategy, limitations, and boolean overwrite caveats
- Use getCharOrNull() and getLongOrNull() to avoid default primitive fallbacks
- Remove verbose toString() override in favor of data class default
- Update PhoneConnection to use the new updateFrom() API
* fix: clear FLAG_KEEP_SCREEN_ON on dispose

* refactor: centralize proximity + wakelock state syncing in dispatcher

* fix: enable proximity sensor only for eligible audio calls
…on accessors (#118)

* refactor: expose PhoneConnection metadata accessors and null-safe dispatcher usage

* refactor: make CallMetadata boolean flags nullable to support partial updates

- Add Bundle safe getters for boolean/string values (getBooleanOrNull, getStringOrNull)
- Change CallMetadata boolean fields to nullable and serialize only when present
- Update bundle parsing to preserve “missing” vs default values
- Adjust call/service logic to use safe fallbacks (?: false) where required
- Tighten PhoneConnection encapsulation and update converters to use accessors

* test: add unit tests for CallMetadata.updateFrom partial-merge behavior

- Enable JVM unit tests in Gradle (src/test/kotlin) and add JUnit + kotlin-test deps
- Add CallMetadataUpdateTest covering patch updates, explicit false overwrites, and list merge rules

* fix: preserve boolean flags in CallMetadata during partial updates

Update the merge strategy to use the Elvis operator (`?:`) for boolean fields (`hasVideo`, `hasSpeaker`, `hasMute`, etc.).

Previously, passing `null` in an update object (e.g., during a display name change) would overwrite the existing `true`/`false` state with `null`. This change ensures existing values are preserved if the update value is missing.

* refactor: rename CallMetadata.updateFrom to mergeWith
#120)

* refactor: consolidate speaker toggle logic across API levels

* feat: enforce speaker routing for video calls to avoid startup race
- Re-evaluate audio routing when endpoints change and when speaker endpoint changes
- Add centralized enforceVideoSpeakerLogic() to keep speaker on for video calls
- Skip auto-speaker when Bluetooth is available (prefer BT over speaker)
- Apply enforcement on answer, dialing, and when upgrading to video

* feat: add speakerOnVideo metadata flag to control auto-speaker

* test: expand CallMetadata mergeWith coverage for speakerOnVideo

- Simplify tests by merging metadata directly (remove FakePhoneConnection)
- Add `speakerOnVideo` merge scenarios (preserve null, preserve explicit false, update null→false, update false→true)
- Keep existing coverage for booleans and audioDevices merge behavior

* test: add Robolectric tests for video auto-speaker behavior

- Add Robolectric/AndroidX test deps and Mockito (core + inline)
- Introduce `PhoneConnectionTest` covering speaker enforcement on video updates
- Verify `speakerOnVideo` config (null/default, true, false) and non-video cases
- Simulate API 34+ call endpoints to avoid startup "endpoints not loaded" early exit

* refactor: rename speakerOnVideo bundle key constant for CallMetadata
…121)

- Remove redundant `id` property in favor of `callId` to maintain consistency with `CallMetadata`.
- Replace `isAnswered()` method and backing field with a idiomatic `hasAnswered` Kotlin property.
- Update references in `ConnectionManager` and `PhoneConnectionService` to match the API changes.
* refactor: remove missed call flow and performReceivedCall API

* refactor: remove unused missed call notification plumbing
#129)

* refactor(android): prevent redundant call endpoint requests and enhance logging

Implemented a concurrency guard using a pending request tracker to ensure only one CallEndpoint change is active at a time, preventing race conditions on Android 14+. Added comprehensive debug logging to the video speaker enforcement logic to improve traceability of audio routing decisions.

- Added @volatile pendingEndpointRequest to track active OutcomeReceiver operations.
- Updated EndpointChangeReceiver to clear the pending request state on both success and failure.
- Injected detailed debug logs into enforceVideoSpeakerLogic to track state transitions.

* fix: synchronize audio endpoint switching to prevent race conditions

* docs: add concurrency notes to performEndpointChange
- Remove manual SensorEventListener for proximity detection.
- Delegate screen state management entirely to Android's PROXIMITY_SCREEN_OFF_WAKE_LOCK.
- Disable WakeLock reference counting (setReferenceCounted(false)) to prevent lock accumulation during rapid call events.
- Persist a single WakeLock instance to prevent orphaned locks from locking the screen indefinitely.
- Add idempotency checks (isListening, isWakelockActive) to ProximitySensorManager to avoid redundant state updates.
* fix: prevent speaker auto-reenabling during video calls

* chore: enhance logging and traceability in ForegroundService
Corrects an issue where new audio-only calls would incorrectly start on
speakerphone if a previous video call was terminated or if the
foreground service was stopped forcefully.

- Added logic to `onAvailableCallEndpointsChanged` to detect the initial
  load of audio endpoints.
- Forces the audio route to EARPIECE during the first initialization
  window for audio-only calls, overriding inherited system heuristics.
- Added documentation explaining the root cause of sticky audio routing
  within the Android Telecom Framework.
- Fixed a Fatal NullPointerException caused by `getCurrentCallEndpoint()`
  returning null during connection setup (despite the @nonnull SDK annotation).
- Resolved the "Sticky Speaker" issue where audio-only calls would
  incorrectly inherit the speakerphone route from previous video calls.
- Removed the conditional check for the current endpoint in
  `onAvailableCallEndpointsChanged` to avoid transient state crashes.
- Implemented a mandatory EARPIECE override on the first hardware load
  for audio-only calls, wrapped in a try-catch for maximum stability.
- Added comprehensive technical documentation explaining the root cause
  of inherited system routing and the defensive strategy implemented.
…udio (#135)

Introduce `preventAutoSpeakerEnforcement` logic to PhoneConnection to ensure
that calls initiated or answered as audio-only do not automatically switch
to speakerphone during a mid-call video upgrade.

- Set `preventAutoSpeakerEnforcement` to true if the call starts without video.
- Guard `enforceVideoSpeakerLogic` to skip enforcement if the flag is active.
- Remove manual speaker disable reset in `applyVideoState` to maintain user preference.

This prevents unexpected loud audio if a user is holding the device to their
ear when a remote party enables video.
- Introduce `CallkeepIncomingCallMetadata` to the platform interface to represent caller details (callId, handle, displayName, hasVideo).
- Update `ForegroundStartServiceHandle` and `CallKeepPushNotificationSyncStatusHandle` typedefs to accept the new metadata parameter.
- Add `PCallkeepIncomingCallData` to Pigeon schemas and update `PDelegateBackgroundRegisterFlutterApi` to pass this data between Android and Dart.
- Modify Android `CallLifecycleHandler` and `IncomingCallService` to capture and forward call metadata to the Dart isolate during push sync and wakeup events.
- Update `README.md` and example project to reflect the new callback signatures.
- Regenerate Pigeon communication files for Android and Dart.
…tartCommand (#138)

On some devices (notably LGE), the system may restart the service with
a null intent or an intent missing its action extra when the app is in
the background. This caused an IllegalArgumentException in
ServiceAction.from() with "Unknown action: null". Now gracefully
returns START_NOT_STICKY when intent or action is null.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
SERDUN and others added 22 commits March 24, 2026 11:01
…n SignalingIsolateService (#224)

* fix(android): always call startForeground() before permission check in SignalingIsolateService

* fix(android): update signalingSkip to reflect actual limitation
… tests pass (#226)

* fix(android): make background signaling and cross-service integration tests pass

Three bugs fixed in SignalingIsolateService:

1. Remove stopSelf() after startForeground() in startForegroundService().
   Android permits a foreground service to run when POST_NOTIFICATIONS is
   absent -- the notification is silently suppressed but the service continues.
   The previous stopSelf() caused ForegroundServiceDidNotStartInTimeException
   on some devices and prevented the background Flutter engine from registering
   its IsolateNameServer command port in environments where the permission is
   absent (e.g. fresh test installs).

2. Replace tearDownService() with sendTearDownConnections() in the non-empty
   branch of endAllCalls().
   tearDownService() dispatches ServiceAction.TearDown whose handleTearDown()
   only synchronises sensor state and never fires HungUp broadcasts.
   sendTearDownConnections() dispatches ServiceAction.TearDownConnections whose
   handleTearDownConnections() calls hungUp() on every PhoneConnection, firing
   the HungUp broadcasts that ForegroundService receives to dispatch
   performEndCall() on the Flutter delegate.

Test-side changes:

3. example/lib/isolates.dart -- rewire onStartForegroundService to register
   an IsolateNameServer command port used by integration tests to inject
   signaling commands (incomingCall / endCall / endCalls) into the background
   Flutter engine.

4. example/integration_test/callkeep_background_services_test.dart:
   - Call initializeCallback(onStartForegroundService) before startService()
     so CALLBACK_DISPATCHER and ON_SYNC_HANDLER are stored in SharedPreferences
     (bootstrap.dart is not executed in test entry-point builds).
   - Move delegate.onPerformEndCall listener before call registrations in the
     signaling endCalls test so early termination events on devices that reject
     concurrent incoming calls are not missed.
   - Add concurrent-call support guard (skip) in the cross-service endCall
     isolation test for devices where Telecom rejects a second incoming call
     while the first is still RINGING.

5. example/android/app/src/androidTest/java/com/example/example/MainActivityTest.java:
   - Grant POST_NOTIFICATIONS via pm grant in @before to handle test
     environments where the permission is not pre-granted.

* fix(test): fix lint warnings in callkeep_background_services_test

- Replace relative lib import with package import (avoid_relative_lib_imports)
- Add explicit SendPort? type annotation instead of dynamic cast

* refactor: apply review comments on PR #226

- Clarify MainActivityTest comment: POST_NOTIFICATIONS grant is for
  general notification reliability
- Use signalingServiceCommandPortName constant in test instead of
  duplicating the string literal
- Add default branch to switch in onStartForegroundService
- Clarify SignalingIsolateService comment: tearDownService() is safe
  in the empty-call branch; sendTearDownConnections() required otherwise
- Update test comment: onStartForegroundService (not _signalingTestCallback)
…lueConst and AndroidPendingCallHandler (#227)

- Delete call_path_key_const.dart and call_path_value_const.dart - these
  constants have no consumers across the monorepo or in webtrit_phone.
- Remove AndroidPendingCallHandler class from android_pending_call_handler.dart -
  the handler was never instantiated; only PendingCall (co-located) is used.
- Update call_const.dart barrel to drop the two deleted exports.
)

The safety-net stopTimeoutRunnable was posted in handleRelease() but never
removed in onDestroy(). When the service stopped gracefully the runnable
still fired ~2 s later, logged a false-alarm warning, and reported a
spurious exception to Firebase Crashlytics.

Fix: call timeoutHandler.removeCallbacks(stopTimeoutRunnable) at the start
of onDestroy() so the timeout is disarmed on any graceful stop path
(normal answer/decline teardown, OS-initiated stop, etc.).
…eepCore (#230)

* refactor(android): route all connection event receiver calls through CallkeepCore

Add registerConnectionEvents/unregisterConnectionEvents to CallkeepCore
interface and InProcessCallkeepCore so that ForegroundService,
IncomingCallService and SignalingIsolateService no longer call
ConnectionServicePerformBroadcaster directly.

This makes CallkeepCore the single access point for both commands to
:callkeep_core and connection event subscriptions, reducing scattered
direct coupling to the broadcaster.

* refactor(android): centralize connection event delivery through CallkeepCore listener hub

Introduce ConnectionEventListener and add/removeConnectionEventListener to
CallkeepCore. InProcessCallkeepCore registers a single lazy global
BroadcastReceiver on the first subscriber and unregisters it when the last
one leaves (ref-counted). ForegroundService and IncomingCallService now
implement ConnectionEventListener instead of registering their own
BroadcastReceivers, removing direct coupling to ConnectionServicePerformBroadcaster.

Per-call one-off receivers (OngoingCall, OutgoingFailure, TearDownComplete,
endCall/endAllCalls confirmations) keep using registerConnectionEvents as
they are temporary and call-scoped.

* refactor(android): simplify endCall/endAllCalls in SignalingIsolateService

Remove per-call BroadcastReceiver, Handler/timeout, and AtomicBoolean
waiting patterns from endCall() and endAllCalls(). Both methods now
dispatch the teardown command through CallkeepCore and resolve the
callback immediately.

The signaling layer (WebSocket/SIP) closes via its own lifecycle in
webtrit_phone and does not depend on Telecom teardown confirmation, so
waiting for HungUp broadcasts before resolving was unnecessary overhead.

* docs(android): update architecture docs to reflect ConnectionEventListener routing

- callkeep-core.md: add Connection Event Listener API section describing
  the lazy ref-counted globalReceiver pattern and global vs per-call events
- foreground-service.md: replace connectionServicePerformReceiver section
  with onConnectionEvent() via ConnectionEventListener; update lifecycle
- ipc-broadcasting.md: update Receiver Locations table -- single
  globalReceiver in InProcessCallkeepCore now fans out to listeners
- background-services.md: document IncomingCallService as ConnectionEventListener
  subscriber; add SignalingIsolateService Pigeon host API table with
  fire-and-forget endCall/endAllCalls behaviour

* fix(android): register connection event receivers as not-exported

globalReceiver in InProcessCallkeepCore and the registerConnectionEvents
default were using RECEIVER_EXPORTED on API 33+, allowing other apps to
spoof call lifecycle and media events. Both are now registered as
not-exported since all senders use setPackage(packageName).
…231)

* refactor(android): replace intra-process broadcasters with StateFlow

ActivityLifecycleBroadcaster and SignalingStatusBroadcaster used
sendBroadcast for communication that never crossed the process boundary --
all senders and receivers live in the main process JVM.

Replace both with StateFlow-backed singletons (ActivityLifecycleState,
SignalingStatusState). SignalingIsolateService collects the flows via a
CoroutineScope tied to the service lifetime instead of registering
BroadcastReceiver instances.

Benefits:
- Eliminates OEM broadcast suppression risk on locked devices
- Removes BroadcastReceiver boilerplate from SignalingIsolateService
- Drops the Context parameter from setValue() -- no longer needed
- Keeps identical currentValue getter API for IsolateSelector call sites

* fix(android): skip initial StateFlow emission in SignalingIsolateService

StateFlow always emits its current value on collection start. Without
drop(1), synchronizeSignalingIsolate() was called during onCreate()
before FlutterEngineHelper was initialized -- an extra call that was
absent in the original BroadcastReceiver approach (receivers only fire
on new broadcasts, not on registration).

Also switch to SupervisorJob so a failure in one collector does not
cancel the other.

* fix(android): replace StateFlow with SharedFlow in state holders

StateFlow deduplicates by value equality -- repeated setValue() calls
with the same value emit nothing to collectors. The integration tests
rely on updateActivitySignalingStatus triggering synchronizeSignalingIsolate
on every poll iteration, which the old sendBroadcast approach did
unconditionally.

Switch to MutableSharedFlow(replay=0, extraBufferCapacity=1) so every
setValue() call delivers an event to active collectors regardless of
whether the value changed. currentValue is kept as a @volatile field
for direct reads by IsolateSelector.

Also removes the drop(1) workaround that was added in the previous
commit -- SharedFlow(replay=0) has no initial replay, so the collector
is only invoked on explicit setValue() calls, matching BroadcastReceiver
registration semantics exactly.

* fix: address review comments on PR #231

- Update stale SignalingStatusBroadcaster -> SignalingStatusState reference
  in CallLifecycleHandlerTest comment
- Update background-services.md to reference .updates (SharedFlow) instead
  of .flow (StateFlow)
- Clarify KDoc on both state holders: document that DROP_OLDEST buffer
  may drop events under backpressure

* ci: re-trigger integration tests

* fix(android): remove unregisterPhoneAccount from tearDown

PR #213 removed it from onDestroy because it breaks phone account
availability for subsequent sessions. Restoring the same behavior:
tearDown only cleans up active calls without unregistering the account.
* feat(example): add per-suite integration test runner scripts

Add _test_lib.sh (shared helpers) and individual run_callkeep_*.sh scripts
for running each integration test suite in isolation on a target device.
Update run_integration_tests.sh to delegate to these scripts.

* fix(example): restore run_integration_tests.sh to main runner format

The CI workflow parses run_integration_tests.sh to discover test files.
The previous version delegated to tools/ scripts and had no inline test list,
causing the grep to find no matches and the CI build to fail immediately.

Restore the file to the standard format with an inline TESTS array so the
workflow can discover and build APKs for each test file.
…re.telecom (#232)

* fix(android): add standalone call mode for devices without android.software.telecom

Devices that do not expose the android.software.telecom system feature
(tablets, Android Go builds, some OEM configs) previously crashed silently
on startup with UnsupportedOperationException from registerPhoneAccount(),
then failed all incoming calls because PhoneConnectionService was never
started and the Pigeon channel was unreachable.

Introduce StandaloneCallService as an independent call manager that takes over
when Telecom is unavailable. It runs in the :callkeep_core process and dispatches
the same ConnectionEvent broadcasts as PhoneConnectionService, so ForegroundService
and the Flutter layer require no changes to support either path.

Changes:
- TelephonyUtils: add isTelecomSupported(context) feature check
- StandaloneCallService: new service managing call state, audio via
  AudioManager directly, and ConnectionEvent broadcast dispatch without
  the Telecom framework
- PhoneConnectionService: route incoming/answer/decline/hangup/audio/teardown
  commands to StandaloneCallService when isTelecomSupported() returns false;
  startOutgoingCall throws UnsupportedOperationException (outgoing calls
  are not supported without Telecom)
- ForegroundService.setUp: skip registerPhoneAccount retry loop when
  Telecom is unavailable; extract applySetupOptions() so notification
  channels and storage options are initialised on both paths
- AndroidManifest: declare StandaloneCallService in :callkeep_core process,
  declare android.software.telecom as optional feature

* refactor(android): centralize call routing in CallServiceRouter

Move all isTelecomSupported() routing out of PhoneConnectionService
companion methods and InProcessCallkeepCore. CallServiceRouter is now
the single place that decides whether to delegate to PhoneConnectionService
(Telecom path) or StandaloneCallService (no-Telecom path).

PhoneConnectionService is restored to a pure Telecom backend with no
routing logic. InProcessCallkeepCore delegates all CS commands through
CallServiceRouter, remaining unaware of which backend is active.

* feat(android): add outgoing call support in StandaloneCallService

Devices without android.software.telecom can now place outgoing calls.
StandaloneCallService handles OutgoingCall and EstablishCall actions:
- OutgoingCall: stores metadata, fires OngoingCall broadcast so
  ForegroundService promotes the call and notifies the Flutter layer
- EstablishCall: activates audio, fires AnswerCall broadcast when the
  remote side connects (mirrors reportConnectedOutgoingCall Telecom path)

CallServiceRouter routes startOutgoingCall and startEstablishCall to
StandaloneCallService instead of throwing UnsupportedOperationException.

* feat(android): implement UpdateCall, SendDtmf, Holding in StandaloneCallService

StandaloneCallService now handles the full set of in-call operations without
requiring android.software.telecom:

- UpdateCall: merges new metadata into the stored call state
- SendDtmf: updates stored dtmf char and fires CallMediaEvent.SentDTMF so
  the Flutter layer receives the confirmation (audio tone is generated by WebRTC)
- Holding: sets AudioManager.MODE_NORMAL on hold / MODE_IN_COMMUNICATION on
  unhold, updates hasHold in stored metadata, fires CallMediaEvent.ConnectionHolding

CallServiceRouter routes all three through route() instead of no-op guards.

* chore(android): remove unused telephonyUtils field from PhoneConnectionService

The instance field was initialized in onCreate() but never read anywhere.
startOutgoingCall() creates its own local TelephonyUtils instance.
Dead code left from earlier refactoring.

* fix(android): defer startForeground() to actual call handling in StandaloneCallService

StandaloneCallService was calling startForeground() unconditionally in onCreate(),
which caused CannotPostForegroundServiceNotificationException when the service was
started for lifecycle-only commands (SyncConnectionState, SyncAudioState, etc.)
before ForegroundService.setUp() had registered the notification channels.

Two root causes fixed:
- startForeground() now called lazily via promoteToForeground(), only from
  handleIncomingCall() and handleOutgoingCall() -- the two handlers that are
  triggered by startForegroundService() and genuinely need foreground status.
- Notification channels are registered in onCreate() to handle the case where
  the service starts before setUp() runs in the main process.

Also stops the service after lifecycle-only commands when no calls are active or
pending, preventing the service from lingering after SyncConnectionState /
SyncAudioState with no ongoing call.

* fix(android): fix tearDownService race, deferred answer, and audio leak in standalone mode

- CallServiceRouter.tearDownService() on standalone path now sends CleanConnections
  instead of TearDownConnections; prevents a second destructive teardown from racing
  with the next test's incoming call (HungUp fired prematurely, service stopped early)
- StandaloneCallService.handleIncomingCall() now consumes pendingAnswers after
  dispatching DidPushIncomingCall, implementing the deferred-answer race handling
  described in the KDoc (ReserveAnswer arriving before IncomingCall is processed)
- deactivateAudio() now resets isSpeakerphoneOn and isMicrophoneMute in addition to
  AudioManager.mode, preventing audio state leaking to other apps after call ends
- Fix exception logging in onStartCommand to pass throwable as argument so the
  full exception is captured

* fix(android): restore startForeground() in StandaloneCallService.onCreate()

Deferring startForeground() to promoteToForeground() (introduced in f601452)
causes ForegroundServiceDidNotStartInTimeException when the service is started
via startForegroundService() on a path that does not reach handleIncomingCall()
or handleOutgoingCall() within 5 seconds.

Restore the immediate startForeground() call in onCreate() now that
notification channels are registered there first, eliminating the original
CannotPostForegroundServiceNotificationException. promoteToForeground() remains
a no-op for subsequent calls because isForeground is already true.

* fix(android): prevent unnecessary StandaloneCallService starts and OEM broadcast suppression

Skip SyncAudioState/SyncConnectionState when StandaloneCallService is not running
to avoid repeated startService/stopSelf cycles that degrade the :callkeep_core
process on devices without Telecom support (process becomes bad after many rapid cycles).

Add FLAG_RECEIVER_FOREGROUND to sendInternalBroadcast() to bypass MediaTek OEM
broadcast suppression observed on Lenovo TB300FU and similar devices.

Remove promoteToForeground() from StandaloneCallService.onCreate() - deferred
startForeground() is only needed when handling an actual incoming/outgoing call.
Calling it before any call exists caused a crash on the login page.

* fix(android): restore promoteToForeground() in StandaloneCallService.onCreate()

The isRunning guard in CallServiceRouter prevents SyncConnectionState and
SyncAudioState from starting StandaloneCallService when no call is active,
which eliminates the login-page foreground notification crash.

However, removing startForeground() from onCreate() introduced a new failure:
on slow-starting MediaTek processes the 5-second startForeground() deadline
expires before onStartCommand() is delivered, causing Android to log
"Bringing down service while still waiting for start foreground" and mark the
process as bad. All subsequent startForegroundService() calls then fail with
"process is bad", breaking every test that requires a cross-process broadcast.

Restoring promoteToForeground() to onCreate() satisfies the deadline
immediately, preventing the bad-process feedback loop. The login-page crash
path no longer exists because the isRunning guard stops the service from
being started before any call is active.

* fix(android): move promoteToForeground() to onStartCommand for call actions only

Calling startForeground(FOREGROUND_SERVICE_TYPE_PHONE_CALL) from onCreate()
crashes the :callkeep_core process on OEM devices (Lenovo TB300FU, Android 13)
when the service is started via startService() rather than startForegroundService().

After enough crashes, ActivityManager marks the process as bad and suppresses
all further service starts, breaking incoming and outgoing calls.

Root cause: lifecycle-only commands (CleanConnections, TearDownConnections, etc.)
are dispatched via communicate() which uses startService(), which does not require
startForeground(). Calling startForeground(PHONE_CALL type) in that context
causes a process crash on this OEM.

Fix: remove promoteToForeground() from onCreate() and call it instead at the
top of onStartCommand(), but only for IncomingCall and OutgoingCall - the two
actions that arrive via startForegroundService() and impose the 5-second window.
onStartCommand() executes on the main thread immediately after onCreate()
returns, so the 5-second requirement is still satisfied.

* fix(android): route StandaloneCallService events in-process via CallkeepCore

On certain OEM devices (e.g. Lenovo TB300FU, Android 13) the system
ActivityManager suppresses sendBroadcast calls from the app, so events
dispatched by StandaloneCallService never reach ForegroundService.

Add CallkeepCore.notifyConnectionEvent() for in-process event delivery.
InProcessCallkeepCore maintains inProcessReceivers alongside the existing
globalReceiver so both persistent ConnectionEventListeners and per-call
dynamic receivers (OngoingCall, TearDownComplete, etc.) receive events
without going through AMS.

StandaloneCallService now calls CallkeepCore.instance.notifyConnectionEvent()
instead of ConnectionServicePerformBroadcaster.handle.dispatch(), keeping
it aligned with the single-facade pattern introduced in PR #230.

* fix(android): run StandaloneCallService in main process with microphone foreground type

Remove android:process=":callkeep_core" so StandaloneCallService shares the same
JVM as ForegroundService. This is required for CallkeepCore.instance.notifyConnectionEvent()
to reach ForegroundService listeners directly without going through AMS.

On Lenovo TB300FU (Android 13) the :callkeep_core process is also permanently marked
as bad after repeated startForegroundService() failures, blocking all subsequent starts.

Change foregroundServiceType from phoneCall to microphone because the phoneCall type
requires the Telecom subsystem, which is absent on devices that use this service.

* fix(android): use FOREGROUND_SERVICE_TYPE_MICROPHONE in StandaloneCallService

The manifest declares foregroundServiceType="microphone" but promoteToForeground()
was still passing FOREGROUND_SERVICE_TYPE_PHONE_CALL to startForeground(), causing
an IllegalArgumentException crash on first incoming call.

* docs: update wip_context to reflect completed fix/standalone-mode-no-telecom-v2

All 10 test suites now pass on HA1SVX8G (Lenovo TB300FU, no telecom).
Document three root causes, final notifyConnectionEvent architecture, and PR #232.

* chore(example): remove test runner scripts, moved to separate PR

* fix(android): guard FOREGROUND_SERVICE_TYPE_MICROPHONE with API 31 check

On API 29-30, FOREGROUND_SERVICE_TYPE_MICROPHONE (0x80) is unknown to
the framework. When startForeground() is called with this type, the
system throws IllegalArgumentException because the requested type does
not match the manifest's type validation on those API levels.

The exception propagated uncaught from onStartCommand() since
promoteToForeground() is called outside the try-catch block, leaving
the 5-second startForeground() window unsatisfied and crashing the
process with:

  Context.startForegroundService() did not then call Service.startForeground()

On API < 31, fall back to the 2-arg startForeground() (no type), which
bypasses type validation entirely and is safe on all API levels below 31.
* fix: release MediaPlayer after stopping ringback

stopRingback() was calling pause() which only suspends the MediaPlayer.
Replaced with stop() + release() + null to fully free the resource.
The next startRingback() will create a fresh instance from scratch.

* fix: use release() instead of stop()+release() in stopRingback

MediaPlayer.stop() can throw IllegalStateException depending on internal
player state. release() stops playback and frees resources unconditionally
in any state, making it the safer and simpler choice.

* fix: guard stopRingback with try/finally to ensure null assignment
…n Android (#239)

* refactor: derive terminated state from active sets instead of explicit guard

Replace the explicit terminatedCallIds set with a derived check across all
active tracking sets (connections, pendingCallIds, pendingAnswers, answeredCallIds).

A call is considered terminated when it is absent from all active sets.
This removes the TTL trade-off entirely: a transfer-back call that re-arrives
with the same ID is never blocked regardless of timing, because addPending
or promote immediately makes isTerminated return false for that ID.

The duplicate endCall guard still holds - once the first endCall removes the
call from all sets, any subsequent endCall sees isTerminated = true and exits early.

* refactor: remove isTerminated guard from reportNewIncomingCall

With derived termination state in MainProcessConnectionTracker, a callId absent
from all active sets is considered terminated. Checking isTerminated before
addPending in reportNewIncomingCall permanently blocked transfer-back calls:
the original call's endCall cleared all sets, leaving isTerminated=true, so the
new incoming call with the same callId was rejected before addPending could
register it as live again.

Remove the isTerminated check entirely. Active-call duplicates are still caught
by isAnswered and exists checks (which must run before addPending since addPending
resets answeredCallIds). Telecom-level stuck connections are caught by
ConnectionManager in :callkeep_core via CALL_ID_ALREADY_TERMINATED during
startIncomingCall, so the guard is not lost - it moves to the process that
owns the authoritative Telecom connection state.

* chore(diag): add logs to trace silent Telecom rejection after transfer back

Added diagnostic logging at three layers to identify why Telecom does not
call onCreateIncomingConnection after addNewIncomingCall:

- TelephonyUtils.addNewIncomingCall: log isInCall/isInManagedCall state
  before dispatch and confirm when addNewIncomingCall is sent to Telecom
- PhoneConnectionService.onCreateIncomingConnection: log entry so we can
  confirm whether Telecom ever calls back at all
- PhoneConnectionService.onCreateIncomingConnectionFailed: use Log.e with
  callId/wasPending context so silent Telecom rejections are visible
- ConnectionManager.checkAndReservePending: log full connection snapshot and
  per-decision reason so we know exactly what state :callkeep_core sees

* fix: pre-register pending callback before startIncomingCall to fix transfer back timeout

DidPushIncomingCall can arrive synchronously - Telecom calls onCreateIncomingConnection
on the binder thread during addNewIncomingCall, which dispatches the DidPushIncomingCall
broadcast before the IPC onSuccess callback returns to the main process. When the callback
and timeout were registered inside onSuccess, resolvePendingIncomingCallback found no entry
and the confirmation was silently dropped, causing the 5-second timeout to fire for every
transfer-back call.

Fix: register pendingIncomingCallbacks[callId] and the safety timeout before calling
startIncomingCall. If startIncomingCall fails (onError), cancel the timeout and remove the
pre-registered callback before invoking the Pigeon callback directly.

* chore(diag): log silent exit paths in onCreateIncomingConnection

Add WARN logs before the isConnectionAlreadyExists and
isExistsIncomingConnection early returns so the next logcat
reveals which guard is blocking the transfer-back incoming call.

* fix(callkeep): allow new incoming call when previous connection is DISCONNECTED

isConnectionAlreadyExists, addConnection and addConnectionAndConsumeAnswer
all used containsKey which treated a STATE_DISCONNECTED PhoneConnection as
a live call. After blind transfer-back the old connection stays in the map
(tearDown not yet called), so the new onCreateIncomingConnection for the
same callId hit the exists-guard and returned createFailedConnection(ERROR)
silently, causing a CALL_REJECTED_BY_SYSTEM timeout.

Fix: treat STATE_DISCONNECTED entries as absent so a new PhoneConnection
can be registered for the same callId in a subsequent transfer-back call.

* refactor(callkeep): consolidate call-state decisions into CallkeepCore facade

Add three higher-level methods to CallkeepCore so ForegroundService works
with intent rather than raw state flags:

- checkIncomingDuplicate: replaces the when-block in reportNewIncomingCall
- markTerminatedWithEndCall: replaces 7 scattered markTerminated+markEndCallDispatched pairs
- routeAnswerCall: replaces the when-block in answerCall returning AnswerCallRoute

No behaviour change.

* refactor(callkeep): rename markTerminatedWithEndCall to clearAndMarkEndCallDispatched

The old name implied a terminated-list write, but markTerminated actually
removes the callId from all tracking sets (derived state). The new name
reflects what the method does: clear call state + record endCall dispatched.

* refactor(callkeep): simplify runCatching pattern in TelephonyUtils

* fix(callkeep): treat disconnected connection as absent in checkAndReservePending

Aligns with isConnectionAlreadyExists and addConnection: when a STATE_DISCONNECTED
entry is found, remove it and reserve the callId as pending so the same ID can
be reused for a new incoming call after blind transfer-back.

Also requires connectionStates.containsKey in isTerminated to prevent a false
positive for callIds that were never tracked, avoiding a spurious performEndCall.

Update unit tests to reflect the new behavior.

* fix(callkeep): clear pending reservation on call termination to allow transfer-back reuse

After a call ends, the main-process ConnectionManager.pendingCallIds entry
from the original registration was never cleaned up. A subsequent
reportNewIncomingCall with the same callId (e.g. blind transfer-back)
would hit checkAndReservePending, find the stale pending entry, and return
CALL_ID_ALREADY_EXISTS instead of allowing the new registration.

Fix: clearAndMarkEndCallDispatched now also calls
PhoneConnectionService.connectionManager.removePending(callId) so the
stale entry is drained when the call terminates. This unblocks transfer-back
while preserving the concurrent-duplicate protection that pendingCallIds
provides during the active ringing window.

* test(android): update callId-reuse tests to expect success after termination

callIdAlreadyTerminated is no longer returned - the isTerminated guard
in reportNewIncomingCall was removed (Layer 1 fix) to allow blind
transfer-back reuse. Update both test suites to reflect the new
behaviour: re-reporting a terminated callId now succeeds (returns null).

* chore: ignore logcat dump and WIP markdown files from spell-check

* chore: remove stale logcat dump and WIP context files from branch

* chore: restore debug files to develop state (revert accidental deletion)

* fix(android): address review comments on callId-reuse changes

- ConnectionManager: switch android.util.Log to project Log wrapper;
  build connections snapshot only on the rejection path to reduce lock
  contention on hot paths
- MainProcessConnectionTracker: clear endCallDispatchedCallIds,
  directNotifiedCallIds and signalingRegisteredCallIds in addPending()
  and promote() so a reused callId does not inherit stale lifecycle
  guards from the previous call (e.g. suppressed performEndCall on
  transfer-back)
- CallkeepCore: update KDoc for clearAndMarkEndCallDispatched to document
  the PhoneConnectionService.connectionManager.removePending side-effect

* chore: exclude logcat dump and WIP markdown files from spell-check

* chore: add technical terms to cspell dictionary to fix spell-check

* chore: delete stale logcat dump and WIP context files

* docs: simplify KDoc for clearAndMarkEndCallDispatched

* docs: fix KDoc for clearAndMarkEndCallDispatched

* docs: remove implementation details from CallkeepCore interface KDocs

* fix: eliminate race between markSignalingRegistered and DidPushIncomingCall broadcast

Move markSignalingRegistered(callId) to before startIncomingCall() so the
suppression guard is set before the IPC round-trip begins. Previously the guard
was set in the onSuccess callback, which could fire after the DidPushIncomingCall
broadcast from :callkeep_core had already been delivered and processed, causing
an unsuppressed didPushIncomingCall to reach Flutter.

Also remove redundant markSignalingRegistered calls from CALL_ID_ALREADY_EXISTS
error paths (guard is already set before startIncomingCall), and add
consumeSignalingRegistered rollback in the generic error path.

In callkeep_background_services_test, add _waitForConnection(id) before
tearDown() so the push-path call has time to propagate from :callkeep_core
to the main-process tracker before getAll() is called.

* fix(android): restore signaling-path didPushIncomingCall suppression

addPending() and promote() were clearing signalingRegisteredCallIds as
part of a lifecycle-guard reset introduced for callId reuse support.
This broke the suppression mechanism for signaling-path calls:

- addPending() is called twice per reportNewIncomingCall: once by
  ForegroundService (before markSignalingRegistered) and again inside
  InProcessCallkeepCore.startIncomingCall (after markSignalingRegistered).
  The second call erased the guard before Telecom was even contacted.

- promote() is called inside handleCSReportDidPushIncomingCall,
  immediately before consumeSignalingRegistered checks the guard.
  Clearing it there defeated the check unconditionally.

Fix: remove signalingRegisteredCallIds.remove from both addPending and
promote. Move the cleanup to markTerminated so that the guard is cleared
when the call ends, covering callId reuse (e.g. transfer-back) without
interfering with the suppression window.

Restores the failing test:
  callkeep_stress_test - reportNewIncomingCall via signaling does not
  fire didPushIncomingCall
* fix(android): add TelephonyManager fallback to isTelecomSupported

Some OEM devices (e.g. ASUS AI2202) have full Android Telecom
infrastructure but do not advertise the android.software.telecom
feature flag in their system build. This caused CallServiceRouter
to route all calls to StandaloneCallService, which then crashed
with SecurityException when starting a foreground service with
FOREGROUND_SERVICE_TYPE_MICROPHONE from a background push handler.

Add a fallback check via TelephonyManager.phoneType: any device
with a GSM, CDMA, or SIP radio has Telecom support. Devices that
return PHONE_TYPE_NONE (Wi-Fi-only tablets, Android Go builds)
correctly fall through to the standalone path.

* fix(android): remove OEM model reference from comment

* fix(android): address review comments on isTelecomSupported

- Replace raw feature string with local FEATURE_TELECOM constant
- Align KDoc to reflect actual condition (any non-NONE phone type)
- Pass exception to logger.w for full stack trace on fallback failure
- Add Robolectric tests for all three routing cases
* refactor: remove signaling service from webtrit_callkeep

Signaling lifecycle (WebSocket, SIP) is now fully owned by the
webtrit_signaling_service plugin. The callkeep plugin is a native call
UI bridge only -- it has no knowledge of WebSocket connections or
signaling state.

Removed from Dart/Pigeon:
- PCallkeepSignalingStatus enum and generated converters
- PCallkeepServiceStatus.mainSignalingStatus field
- PHostConnectionsApi.updateActivitySignalingStatus
- PHostBackgroundSignalingIsolateBootstrapApi and PHostBackgroundSignalingIsolateApi
- CallkeepSignalingStatus model and all related converters
- BackgroundSignalingBootstrapService / BackgroundSignalingService
- updateActivitySignalingStatus from CallkeepConnections and platform interface
- Signaling service stubs from webtrit_callkeep_web

Removed from Android/Kotlin:
- SignalingIsolateService and ForegroundCallNotificationBuilder
- BackgroundSignalingIsolateBootstrapApi
- SignalingStatus, SignalingStatusState
- ForegroundCallBootReceiver, SignalingServiceBootWorker
- SignalingStatusStrategy from IsolateSelectionStrategy
- StorageDelegate.SignalingService nested object
- android.permission.RECEIVE_BOOT_COMPLETED permission

Simplified IsolateLaunchPolicy.shouldLaunch() -- SignalingIsolateService
running check removed; decision is based on ActivityStateStrategy only.

Updated example app, integration tests, unit tests, and AGENTS.md.

* docs: remove signaling service references from all documentation

* refactor: remove orphaned onApplicationStatusChanged Pigeon callback

The `onApplicationStatusChanged` method in `PDelegateBackgroundRegisterFlutterApi`
was never called from Kotlin after the signaling service was removed. Remove it
from the Pigeon definition, regenerate bindings, and drop the associated
`ForegroundChangeLifecycleHandle` typedef which was only used by that handler.

* docs: explain rationale for removing persistent signaling isolate

* docs: simplify signaling removal rationale to a single paragraph

* docs(plugin): clarify background-services.md link description

* chore(incoming-call): log syncPushIsolate result on isolate start for diagnostics

* fix: replace status-based push isolate lifecycle with unconditional releaseCall (#242)

* feat: replace status-based push isolate lifecycle with unconditional releaseCall

- Remove PCallkeepPushNotificationSyncStatus enum and status param from
  onNotificationSync callback; native side now triggers isolate with
  metadata only, isolate manages its own lifecycle end-to-end
- Add releaseCall(callId) to PHostBackgroundPushNotificationIsolateApi;
  terminates PhoneConnection via Telecom and stops IncomingCallService
  unconditionally without depending on connection state
- Add independent 60s timeout to IncomingCallService as safety net when
  isolate crashes or never calls releaseCall
- Remove releaseResources from FlutterIsolateCommunicator; isolate now
  cleans up its own signaling resources before calling releaseCall
- Fix NotificationManager.cancelIncomingNotification to guard against
  restarting IncomingCallService when already stopped via releaseCall

* fix: update CallLifecycleHandlerTest after removing releaseResources from FlutterIsolateCommunicator

* fix: guard IC_RELEASE_WITH_DECLINE in IncomingCallService instead of NotificationManager

* fix: removeCallbacks before postDelayed in handleLaunch, guard null callId in example releaseCall

* feat: regenerate Pigeon files from callkeep.messages.dart

* fix: remove unused PCallkeepSignalingStatus import from Extensions.kt

* fix: remove unfinished SignalingStatus converters and tests from this branch

* fix: remove unused import in example isolates.dart

* chore: exclude generated Pigeon and .g.dart files from formatter

* chore: remove unsupported formatter.exclude option

* feat: regenerate Pigeon files

* docs: fix spelling optimisation -> optimization

* docs: update background services test comments to reflect releaseCall API

* fix: remove IsolateLaunchPolicy and IsolateSelector (#243)

* feat: remove IsolateLaunchPolicy and IsolateSelector - IncomingCallService always runs in background isolate

* fix: update stale launchSignaling reference to run() in integration test comment

* fix: reject IC_INITIALIZE when service is already initialized

When a second FCM push arrives within the 2s teardown window of a declined
call, handleLaunch() was called on an already-running service. This cancelled
the pending stop timer, overwrote currentCallData, and started a second Flutter
isolate on the same engine - causing FlutterEngine conflicts, callRejectedBySystem,
and corrupted service state.

Guard: if isInitialized is true, drop the new IC_INITIALIZE. Telecom rejects
concurrent ringing calls anyway; the push will reach a fresh service instance
once the current one stops cleanly.

* fix: skip background isolate launch when main app is active

IsolateSelector (removed in the previous commit) checked ActivityLifecycleState
to suppress isolate launch when the activity was ON_RESUME/ON_PAUSE/ON_STOP.
Without that guard the isolate always launched - even when the main app was in
the foreground with its own SignalingModule already open. This created a second
WebSocket connection; both sides received IncomingCallEvent, fought over the
same Telecom slot, and produced callRejectedBySystem followed by a decline loop.

Inline the same lifecycle check directly in maybeInitBackgroundHandling()
without the abstraction layer. When state is null or ON_DESTROY the app is
killed/not started and the isolate launches normally.

* fix: remove orphaned web override and update IncomingCallHandler docs

- Remove configurePushNotificationSignalingService no-op from
  WebtritCallkeepWeb: method was deleted from the abstract platform
  interface so the override no longer overrides anything
- Update IncomingCallHandler KDoc: replace stale "according to policy" /
  "(if policy permits)" wording with accurate "unless the main app is
  already active" to match current behavior
…epAndroidOptions (#244)

* feat(android): expose configurable call timeout via CallkeepAndroidOptions

Add incomingCallTimeoutMs and outgoingCallTimeoutMs to CallkeepAndroidOptions
so callers can align the no-answer disconnect behaviour with their SIP server.
Values are persisted via StorageDelegate.Timeout and read by ConnectionTimeout
factory methods at connection creation time. Default remains 35 000 ms when
not specified.

Fixes WT-1273

* chore(android): change default call timeout from 35s to 60s

* chore(android): set Dart default for call timeout to 60s

* fix(android): make call timeout fields non-nullable, add StorageDelegate tests

- incomingCallTimeoutMs/outgoingCallTimeoutMs changed from int? to int
  in CallkeepAndroidOptions (default 60000 always sent over Pigeon,
  docs updated to reflect that null is no longer a valid state)
- Add StorageDelegateTimeoutTest: default 60s, set/get round-trip,
  incoming and outgoing stored independently
* fix(android): start ringtone in StandaloneCallService for no-telecom devices

On devices where android.software.telecom is unavailable the app falls
back to StandaloneCallService. Unlike the Telecom path (PhoneConnection.
onShowIncomingCallUi), this service never called AudioManager.startRingtone,
so incoming calls were completely silent.

Add ringtoneManager (CallkeepAudioManager) to StandaloneCallService:
- startRingtone in handleIncomingCall
- stopRingtone in handleAnswerCall, handleEstablishCall,
  handleDeclineCall, handleHungUpCall, handleTearDownConnections

Fixes WT-1226. Reproduces on OPPO Android 12 and Android 8 devices
where android.software.telecom is stripped from the OEM ROM.

* fix(android): stop ringtone in onDestroy of StandaloneCallService

If the service is killed via CleanConnections or other paths that bypass
normal call-end handlers, the ringtone could keep playing after the
service stops. Stop it unconditionally in onDestroy as a safety net.
* fix(android): always acquire screen wake lock on incoming call

When the screen is locked but the app is active in the foreground
(Activity state ON_STOP), Android suppresses the full-screen intent
Activity launch because the app is considered already visible. In this
case the previous "skip wake lock if full-screen intent is available"
guard caused the ringtone to play on a dark screen with no call UI.

SCREEN_BRIGHT_WAKE_LOCK | ACQUIRE_CAUSES_WAKEUP and the full-screen
intent are complementary: the intent launches the call UI; the wake
lock physically turns the screen on. Removing the early-return guard
ensures the screen wakes on all devices regardless of Activity state.

Also add FLAG_TURN_SCREEN_ON and FLAG_SHOW_WHEN_LOCKED to the intent
flags in buildOpenAppIntent() so that when the Activity is launched via
the full-screen intent it can show over the keyguard on Android 14+
and HyperOS.

Reproduces on: Xiaomi 25028RN03Y, Android 15 (HyperOS)

* fix(android): revert incorrect Intent flags in NotificationBuilder

FLAG_TURN_SCREEN_ON and FLAG_SHOW_WHEN_LOCKED are WindowManager.LayoutParams
flags, not Intent flags. The previous commit added them to the Intent object,
causing an Unresolved reference compilation error.

Reverted NotificationBuilder.kt to its original flags. The screen wake-up
fix relies solely on always acquiring SCREEN_BRIGHT_WAKE_LOCK in
IncomingCallService, which is the correct mechanism from a Service context.
…de (WT-1227) (#249)

* fix(android): show proper incoming call notification in standalone mode (WT-1227)

On devices without android.software.telecom the standalone path posted a
low-importance placeholder notification (no buttons, no CallStyle) as the
foreground notification and never replaced it, so users had no way to
answer or decline from the notification panel.

Add StandaloneIncomingCallNotificationBuilder that mirrors
IncomingCallNotificationBuilder but routes Answer/Decline PendingIntents
directly to StandaloneCallService via StandaloneServiceAction. Call
showIncomingCallNotification() from handleIncomingCall() immediately after
promoteToForeground() so the placeholder is replaced with a proper
high-importance notification including Notification.CallStyle on API 31+
and explicit action buttons on API 29-30.

* refactor(android): address PR review on WT-1227 standalone notification

- Extend NotificationBuilder so buildOpenAppIntent(context) is reused from
  the base class instead of duplicated
- Guard setFullScreenIntent with a null check on Platform.getLaunchActivity()
  to prevent passing a null Intent to PendingIntent.getActivity()
- Extract foregroundServiceType to a class-level property in
  StandaloneCallService, removing the duplicated if/else in both
  promoteToForeground() and showIncomingCallNotification()
- Switch to setCallMetaData/build() pattern to match IncomingCallNotificationBuilder
SERDUN and others added 6 commits April 14, 2026 17:22
…ll (WT-1300) (#251)

* fix(android): silence ringtone on volume key press during incoming call (WT-1300)

For self-managed calls Telecom routes volume key events to the app via
Connection.onSilence() instead of adjusting the audio stream directly.
PhoneConnection did not override this method, so the ringtone kept playing
regardless of how many times the volume button was pressed.

Confirmed on Xiaomi/MIUI and Samsung/One UI, Android 11.

* fix(android): remove issue reference from onSilence comment

* fix(android): lower onSilence log level from i to d (WT-1300)

* test(android): add onSilence unit tests and inject AudioManager (WT-1300)

Inject AudioManager as an optional constructor parameter (default keeps
existing behaviour for all production callers). This allows tests to pass a
mock and verify stopRingtone() is invoked.

Add two tests:
- onSilence stops the ringtone on a single volume-key press
- onSilence called multiple times stops ringtone each time (repeated hold)
…#258)

* fix(android): acquire screen WakeLock only when USE_FULL_SCREEN_INTENT is unavailable (WT-1306)

PR #217 added SCREEN_BRIGHT_WAKE_LOCK | ACQUIRE_CAUSES_WAKEUP in IC_INITIALIZE
unconditionally. This woke the device from Doze before the FSI notification was
posted. On an already-awake device, SystemUI no longer fires FSI as part of a
Doze-exit sequence. VoipCallMonitor (Android 14+) then intercepts the CallStyle
notification and suppresses FSI because self-managed connections are not tracked
in its call registry. Result: full-screen call UI never appears.

callkeep 0.4.1 worked because the device was still in Doze when the notification
arrived. SystemUI fired FSI first and won the race against VoipCallMonitor.

Fix: check NotificationManager.canUseFullScreenIntent() (API 34+). When FSI is
granted, skip the WakeLock so the device stays in Doze until the FSI notification
is posted and SystemUI can fire it. When FSI is denied (MIUI/HyperOS), keep the
WakeLock as the only available mechanism to wake the screen.

* fix(android): use distinct placeholder notification ID to avoid suppressing FSI (WT-1306)

IncomingCallService.onCreate() posts a placeholder notification immediately to
satisfy Android's 5-second startForeground() deadline. Previously it used the
same notification ID (2) as the real incoming-call notification posted later
in IncomingCallHandler.showNotification().

When the real notification (with fullScreenIntent) replaced the placeholder via
startForeground(id=2, ...), the system treated it as an UPDATE to an already-
posted notification. FSI fires only for newly-posted notification IDs, not
for updates to existing ones -- so the full-screen call UI never appeared.

In callkeep 0.4.1 there was no placeholder: the first startForeground() call
used the real notification (id=2 with FSI), so the system treated it as new
and fired FSI correctly.

Fix: use PLACEHOLDER_NOTIFICATION_ID (= NOTIFICATION_ID + 10) for the onCreate()
placeholder. When IncomingCallHandler later posts the real notification with
NOTIFICATION_ID=2, the system sees it as a new notification and fires FSI.
Android automatically removes the placeholder when the FGS transitions to the
new notification ID.

* fix(android): derive incoming call notification ID from callId (WT-1306)

A fixed notification ID (NOTIFICATION_ID=2) meant that consecutive incoming
calls reused the same ID. The system treated the second (and subsequent)
notifications as updates to the first, suppressing fullScreenIntent on each
one. The same issue caused the FGS placeholder collision fixed in the
previous commit, but also affected back-to-back calls.

Using callId.hashCode() as the notification ID gives each call a unique,
stable identifier. The system always sees a brand-new notification, so FSI
fires reliably for every incoming call.

Changes:
- IncomingCallNotificationBuilder: add notificationId(callId) companion
  function; updateToReleaseIncomingCallNotification() derives ID from the
  stored callMetaData
- IncomingCallHandler: replace all NOTIFICATION_ID references with
  currentNotificationId (computed from lastMetadata.callId)
- IncomingCallService: PLACEHOLDER_NOTIFICATION_ID is now a fixed sentinel
  (3) independent of NOTIFICATION_ID

* fix(android): correct infinite recursion in currentNotificationId fallback

* fix(android): remove unreachable NOTIFICATION_ID fallback in currentNotificationId

* fix(android): remove legacy NOTIFICATION_ID=2 constant and unreachable fallback

* fix(android): harden notificationId() and improve null safety (WT-1306)

- notificationId(): remap into [1000, Int.MAX_VALUE] using
  (hashCode and Int.MAX_VALUE).coerceAtLeast(1000) to guarantee
  no collision with reserved IDs (placeholder=3, ActiveCall=1,
  StandaloneCall=97); add MIN_CALL_NOTIFICATION_ID constant
- updateToReleaseIncomingCallNotification(): replace callMetaData!!
  with requireNotNull(callMetaData) for consistent null handling
- isFullScreenIntentAvailable() KDoc: clarify that returning false
  on API < 34 is intentional (WakeLock is correct on older Android
  where VoipCallMonitor does not exist)
…API (#256)

* fix(android): skip PhoneConnection teardown in releaseCall for answered calls

Root cause: CallLifecycleHandler.releaseCall() unconditionally called
terminateCall() for every callId, including calls already answered via
the push notification path. When the push isolate closes (Activity takes
over), close() -> releaseCall() destroyed the PhoneConnection before the
Activity could adopt it.

The failure was silent for the first push call because ForegroundService
was not yet running when the HungUp broadcast fired, so the broadcast was
missed and STATE_ACTIVE remained intact. For calls arriving within the
10-second pushBound grace window ForegroundService was already running and
processed the broadcast immediately, clearing STATE_ACTIVE before the
Activity reportNewIncomingCall arrived, leaving CallBloc stuck at
incomingFromOffer with no performAnswerCall ever delivered.

Fix: add isCallAnswered lambda to CallLifecycleHandler and wire it to
CallkeepCore.instance.isAnswered() in IncomingCallService. releaseCall()
now skips terminateCall() when the call is already answered, so the
PhoneConnection stays ACTIVE and the Activity can adopt it via the
existing CALL_ID_ALREADY_EXISTS_AND_ANSWERED path in reportNewIncomingCall.

Also fix a related messenger race in WebtritCallkeepPlugin: the shared
messenger field is overwritten by every onAttachedToEngine() call
(Activity + push isolates). Capture activityBinaryMessenger synchronously
in onAttachedToActivity, before the async bindForegroundService(), and use
it when creating PDelegateFlutterApi in onServiceConnected so that
performAnswerCall is always delivered to the Activity engine.

* fix(android): split releaseCall into releaseCall / handoffCall in push isolate API

releaseCall() was called for both unanswered calls (terminate + stop) and
answered calls (hand off to Activity, stop only). This unconditional
terminateCall() destroyed the PhoneConnection before the Activity could
adopt it via CALL_ID_ALREADY_EXISTS_AND_ANSWERED, leaving CallBloc stuck
at incomingFromOffer.

Split the single ambiguous method into two with explicit semantics:

  releaseCall(callId) - unanswered call: terminateCall + stopService
  handoffCall(callId) - answered call: stopService only, PhoneConnection stays alive

Changes:
- pigeons/callkeep.messages.dart: add handoffCall to
  PHostBackgroundPushNotificationIsolateApi (Pigeon definition)
- Generated.kt / callkeep.pigeon.dart: regenerated Pigeon output
- CallLifecycleHandler: implement handoffCall (stopService only),
  restore releaseCall to single-purpose terminate + stop
- IncomingCallService: remove isCallAnswered lambda (no longer needed)
- BackgroundPushNotificationService: expose handoffCall() with docs
- WebtritCallkeepPlatformInterface: add handoffCallBackgroundPushNotificationService
- WebtritCallkeepAndroid: wire handoffCall() to Pigeon

Also fix BinaryMessenger race in WebtritCallkeepPlugin: capture
activityBinaryMessenger synchronously in onAttachedToActivity so that
flutterDelegateApi always uses the Activity engine messenger rather than
a push-isolate engine that may have called onAttachedToEngine() after
onAttachedToActivity but before the async onServiceConnected fires.

Companion change required in webtrit_phone:
PushNotificationIsolateManager.close() must call handoffCall() instead
of releaseCall() when the call was answered.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants