Draft
Conversation
- 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 microphone checking for call
* 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>
…on to internal (#220)
… ActiveCallService (#223)
…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
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 1.0.0