Skip to content

release: 0.5.0#235

Open
SERDUN wants to merge 52 commits intomainfrom
release/0.5.0
Open

release: 0.5.0#235
SERDUN wants to merge 52 commits intomainfrom
release/0.5.0

Conversation

@SERDUN
Copy link
Copy Markdown
Member

@SERDUN SERDUN commented Mar 31, 2026

Release 0.5.0

What's new

feat(android): dual-process architecture — :callkeep_core OS process split (#200)
Separates the CallKeep core into a dedicated Android process for improved stability and lifecycle isolation.

feat: pass incoming call metadata to background isolate callbacks (#137)
Background isolates now receive full call metadata on incoming call events.

feat(example): per-suite integration test runner scripts (#233)

Fixes

Refactoring

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 <[email protected]>
SERDUN and others added 22 commits March 13, 2026 11:34
* chore: add CLAUDE.md, AGENTS.md, Claude hooks, and git tooling

- Add root CLAUDE.md as entry point for Claude Code
- Add root AGENTS.md with build commands, code style, and architecture overview
- Add package-level AGENTS.md for platform_interface, android, and ios packages
- Add .claude/settings.json with allow/deny permissions and PostToolUse/PreToolUse hooks
- Add .claude/hooks: dart_formatter, newline_enforcer, secrets_guard
- Add lefthook.yml for pre-commit formatting, commit-msg validation, and pre-push analysis
- Add tool/scripts: commit-msg-check.sh and branch-name-check.sh

* chore: add CLAUDE.md and AGENTS.md to all key packages

Add package-level CLAUDE.md (Claude Code entry point) to:
- webtrit_callkeep — aggregator with singleton API overview
- webtrit_callkeep_android — Android impl with critical rules
- webtrit_callkeep_ios — iOS impl with critical rules
- webtrit_callkeep_platform_interface — pure Dart contract

Add AGENTS.md to webtrit_callkeep with full class and API documentation.

Enables each package to be opened as a standalone project in Claude Code
without losing context about architecture and conventions.

* chore: add md_formatter hook and register in settings

* fix(lefthook): use recursive glob **/*.dart for dart-format command

* fix(lefthook): remove explicit test path from flutter analyze

* fix(scripts): enforce lowercase first char with [a-z] check in commit-msg-check

* fix(scripts): enforce kebab-case after slash in branch-name-check regex

* fix(docs): replace hardcoded branch name with generic guidance in CLAUDE.md

* fix(docs): remove non-existent test path from commands and correct line-length to 120

* fix(docs): remove non-existent test directory from platform_interface commands

* fix(hooks): align dart format line-length with analysis_options page_width (120)

* docs: extract git flow conventions into CONTRIBUTING.md
* chore: replace very_good_analysis with flutter_lints/lints across all packages

Standardize analysis_options.yaml to match webtrit_phone style:
- flutter_lints for Flutter packages, lints for pure Dart (platform_interface)
- keep only prefer_single_quotes and page_width: 120
- remove disabled very_good_analysis rules and TODO comments
- fix outdated analysis_options.5.1.0.yaml reference in linux/macos/windows

* fix(example): resolve analysis warnings after linter migration

- remove @OverRide from performSetSpeaker and performReceivedCall (not in CallkeepDelegate)
- replace unused meta import with foundation.dart show immutable in tests_cubit
- use context.mounted instead of mounted for use_build_context_synchronously

* docs: fix typos in AGENTS.md files

* style: apply dart format --line-length 120 to android package

* ci: fix format/analyze directories for packages without test/

* style: apply dart format --line-length 120 to webtrit_callkeep package

* style: apply dart format --line-length 120 to ios package

* style: apply dart format --line-length 120 to platform_interface package

* chore: remove local session file and add to gitignore

* style: apply dart format --line-length 120 to ios test files

* docs: fix spelling issues found by spell-check CI

* ci: replace deprecated macos-13 runner with macos-latest

* ci: disable unsupported platform workflows (linux, macos, windows)
…cture (#200)

* feat(example): overhaul example app with structured logging and multi-line support (#149)

* feat(example): overhaul example app with structured logging and multi-line support

Replace freezed-based ActionsState with a plain Dart implementation.
Introduce LogEntry/EventLogView for structured, color-coded event logging.
Add multi-line (CallLine) management to track multiple simultaneous calls.
Refactor TestsCubit/TestsState to use the same log model.
Add WebtritCallkeepLogs delegate support in ActionsCubit.

* fix(example): resolve BuildContext async gap warnings in main_screen

* fix(android): replace println with Log.d in SignalingIsolateService (#151)

* fix(android): replace println with Log.d in SignalingIsolateService (#143)

println() bypasses the project Log abstraction and leaks signaling
status values directly to raw logcat in production builds.
Replace with Log.d(TAG, ...) consistent with all other log calls
in the same class.

* fix(android): fix misleading log message in synchronizeSignalingIsolate

Log both wakeUpHandler and status values instead of labeling status
as wakeUpHandler in the debug message.

* fix(android): use consistent context to unregister broadcast receivers (#144) (#150)

ActivityLifecycleBroadcaster was registered with 'this' in onCreate
but unregistered with 'baseContext' in onDestroy. The mismatched
context left lifecycleEventReceiver registered after service
destruction, causing a memory leak and callbacks into a dead service.

Use 'this' for both unregister calls, matching the register call-sites.

* fix(android): remove force-unwrap on latestLifecycleActivityEvent (#153)

* fix(android): cache WakeLock in SignalingIsolateService to prevent leak (#142)

* fix(android): cache WakeLock in SignalingIsolateService to prevent leak

getLock() was calling PowerManager.newWakeLock() on every invocation,
so onStartCommand() and onDestroy() operated on different objects.
The original lock was never released, accumulating PARTIAL_WAKE_LOCK
entries across WorkManager/boot restarts and keeping the CPU permanently
awake.

Cache the instance in a companion-object @Volatile field with a
@Synchronized lazy-init block (same pattern as ProximitySensorManager).
Use context.applicationContext to avoid holding a reference to the
service context inside the companion.

Add SignalingIsolateServiceWakeLockTest (Robolectric) covering:
- same instance returned on repeated calls
- acquired lock is held
- released lock is not held
- release guard is safe when lock was never acquired

* fix(android): address review comments on WakeLock fix

- Rename WAKE_LOCK_TAG from ForegroundCallService.Lock to
  SignalingIsolateService.Lock so dumpsys power output correctly
  identifies the owner
- Restrict resetWakeLock() visibility to internal and annotate with
  @VisibleForTesting(otherwise = PRIVATE) to prevent accidental
  invocation from production code

* fix(android): remove force-unwrap on latestLifecycleActivityEvent (#145)

Field was declared nullable but always initialised to ON_DESTROY,
making !! fragile — a future init-order change would crash instead
of degrading gracefully.

Change type to non-nullable Lifecycle.Event. Use Elvis (?: return)
when parsing the broadcast intent so an unparseable bundle skips
the sync call rather than crashing. Use ?: ON_DESTROY as fallback
when reading currentValue in onCreate.

* fix(android): address review comments on SignalingIsolateService

- Fix misleading log message: log both wakeUpHandler and status values
- Release cached wakeLock instance in onDestroy instead of calling getLock
  to avoid allocating a new WakeLock during teardown
- Add Robolectric tests verifying fromBundle guard returns null for
  missing/invalid lifecycle broadcast extras, preventing regression
  of the original NPE fix

* fix(android): release WakeLock in resetWakeLock and use Context.POWER_SERVICE (#154)

- resetWakeLock now releases the held lock before clearing the reference
  to prevent resource leaks when called mid-test
- Use Context.POWER_SERVICE instead of unqualified POWER_SERVICE for
  consistency with the rest of the codebase

* refactor(android): remove FlutterAssets dependency from AssetHolder and FlutterAssetManager (#156)

* refactor(android): remove FlutterAssets dependency from AssetHolder and FlutterAssetManager

FlutterAssets was threaded through WebtritCallkeepPlugin -> AssetHolder ->
FlutterAssetManager solely to produce the path "flutter_assets/<name>",
which is a fixed Flutter convention that never changes.

Remove FlutterPlugin.FlutterAssets from FlutterAssetManager and AssetHolder
entirely. FlutterAssetManager now resolves paths directly using the standard
convention. AssetHolder.init() takes only a Context and works identically
from any OS process -- main, background service, or isolated :callkeep_core --
without requiring a live FlutterEngine.

WebtritCallkeepPlugin no longer stores or forwards flutterAssets.

* refactor: replace FlutterAssetManager with AssetCacheManager, inject path resolver

- Rename FlutterAssetManager -> AssetCacheManager: class is process-agnostic
  and has no Flutter SDK dependency, so the name was misleading
- Inject assetPathResolver: (String) -> String into AssetCacheManager and
  AssetHolder.init(), delegating path convention to the caller
- WebtritCallkeepPlugin passes FlutterAssets.getAssetFilePathByName() as the
  resolver -- FlutterAssets stays confined to the plugin boundary
- Isolated-process callers (PR-9b) will pass { "flutter_assets/$it" } directly
- Update AudioManager: AssetHolder.flutterAssetManager -> assetCacheManager

* refactor: extract FLUTTER_ASSETS_DIR constant, remove assetPathResolver injection

AssetCacheManager owns the flutter_assets/ path convention via a documented
private constant (FLUTTER_ASSETS_DIR). The path is stable across Flutter versions
and identical for all processes -- injecting a resolver added complexity with no
real benefit. AssetHolder.init() takes only a Context and works the same way in
both the main process and any isolated OS process (e.g. :callkeep_core).

* refactor: merge AssetHolder into AssetCacheManager singleton

AssetHolder was a redundant wrapper -- AssetCacheManager now owns both
the singleton lifecycle (init/guard) and the caching logic. Removes one
layer of indirection; all call sites use AssetCacheManager directly.

* test: add AssetCacheManagerTest covering init, cache hit, and path resolution

Tests cover:
- getAsset() throws IllegalStateException before init()
- init() is idempotent (second call does not reset state)
- getAsset() returns file URI from cacheDir when asset is already cached
- cache hit bypasses APK asset read entirely
- path resolution uses FLUTTER_ASSETS_DIR prefix (lastPathSegment keying)

Also replaces io.flutter.Log with android.util.Log and androidx.core.net.toUri
with Uri.parse() -- AssetCacheManager has no Flutter SDK dependency.

* fix: tighten AssetCacheManager API and remove dead code

- Change getAsset() return type Uri? -> Uri: the method never returns null,
  it either succeeds or throws (IllegalStateException / IOException).
  Removes misleading null checks in callers.
- Remove no-op try/catch in cacheAsset(): catching IOException only to
  rethrow it adds no value; let the exception propagate naturally.
- Update AudioManager.getRingtone(): remove dead null-check branch now
  that getAsset() is non-nullable.
- Update AssetCacheManagerTest: drop assertNotNull where return is non-null.

* docs: document why cacheAsset copies APK assets to disk

* docs: add KDoc to all AssetCacheManager public methods

* fix: pass foregroundServiceType to startForeground in IncomingCallService (#159)

On API 34+ startForeground() called without a foreground service type
throws InvalidForegroundServiceTypeException when the service declares
android:foregroundServiceType in the manifest. IncomingCallService
declares foregroundServiceType="phoneCall" but showNotification() was
calling startForegroundServiceCompat() without the type argument,
resulting in the FGS failing with "Can not find ServiceRecord" and the
incoming call being immediately disconnected with REMOTE cause on
Android 14+/15 devices.

* fix(android): make terminateWithCause idempotent (#163)

* fix(android): make terminateWithCause idempotent by always dispatching broadcast

Remove the custom disconnected field and rely on the Telecom framework's own
state (STATE_DISCONNECTED) as the single source of truth for double-termination
protection. The disconnected field was redundant: it duplicated state already
tracked by the Connection base class.

The confirmation broadcast (HungUp/DeclineCall) is now always dispatched, even
when the connection is already in STATE_DISCONNECTED. This fixes a race where
the remote party hangs up just before the local endCall is initiated: the
original broadcast fires before the endCall confirmation receiver is registered,
so the receiver misses it and falls back to the 5s timeout unnecessarily.

Re-dispatch in the else branch uses the stored disconnectCause (set during the
original setDisconnected call) so consumers receive the same event type that
was fired during the first disconnect.

Broadcast consumers are idempotent: ForegroundService and IncomingCallService
forward to Flutter which guards with wasHungUp state check; the one-shot endCall
confirmation receiver in SignalingIsolateService is guarded by AtomicBoolean.

* refactor(android): extract eventForDisconnectCause helper and fix KDoc reference

Extract the disconnect-cause-to-event mapping into a private helper to eliminate
duplication between onDisconnect() and the already-disconnected branch of
terminateWithCause(). Also replace the unresolved KDoc symbol link [disconnected]
with plain backtick text since that field no longer exists.

* docs(android): rewrite terminateWithCause KDoc to describe current state only

* test(android): cover terminateWithCause idempotency and race condition

Add PhoneConnectionTerminateTest with 12 Robolectric tests across three groups:
1. Normal disconnect: LOCAL/REMOTE/REJECTED cause dispatches the correct event,
   state transitions to STATE_DISCONNECTED, onDisconnectCallback fires once.
2. Already-disconnected (idempotency): second terminateWithCause re-dispatches
   using stored disconnectCause; cleanup does not repeat; stored cause wins.
3. Race condition: setDisconnected(REMOTE) before terminateWithCause(LOCAL) still
   dispatches DeclineCall immediately, fixing the 5-second endCall timeout.

Update build.gradle to add flutter.jar and androidx.core:core-ktx so tests
compile and run standalone via ./gradlew testDebugUnitTest.

* fix(android): block duplicate incoming call IDs with atomic pending reservation (#164)

* fix(android): block rapid duplicate incoming call IDs via pending set

reportNewIncomingCall returned null for rapid same-ID duplicates because
validateConnectionAddition ran against an empty connectionManager - the
actual connection is only registered in connectionManager after Telecom
calls onCreateIncomingConnection, which is async.

Add pendingCallIds (ConcurrentHashMap.newKeySet) to the PhoneConnectionService
companion object. startIncomingCall adds the call ID to pendingCallIds before
addNewIncomingCall(); validateConnectionAddition checks pendingCallIds first
and rejects duplicates with CALL_ID_ALREADY_EXISTS. onCreateIncomingConnection
and onCreateIncomingConnectionFailed both remove the ID once Telecom handles it.

Also add integration_test/ to the example app with stress scenarios that
cover deduplication, lifecycle callbacks, and rapid-succession edge cases.

* refactor(android): move pendingCallIds into ConnectionManager

pendingCallIds belongs in ConnectionManager since it is part of connection
state tracking. Expose addPending/removePending/isPending instance methods
and check isPending inside validateConnectionAddition via the existing
manager reference. PhoneConnectionService.startIncomingCall delegates to
connectionManager.addPending/removePending instead of a companion-level set.

Benefit: replacing connectionManager with a fresh instance (e.g. in tests)
automatically clears the pending set with no extra teardown step.

* fix(android): make pending reservation atomic under connectionResourceLock

The previous approach had a TOCTOU race: isPending() and addPending() were
separate operations, so two concurrent Pigeon calls could both observe
isPending=false and both proceed past validation before either reserved
the slot.

Replace isPending/addPending with checkAndReservePending() which performs
the full validation (pending, disconnected, already-exists) and the
pendingCallIds.add() in a single synchronized(connectionResourceLock) block.
validateConnectionAddition() now delegates entirely to checkAndReservePending,
making the check-then-act sequence uninterruptible.

* fix(android): resolve race conditions in call lifecycle causing test failures

- Add volatile `terminated` flag to PhoneConnection for cross-thread
  visibility; set it before dispatching in onDisconnect() to prevent
  double performEndCall from concurrent threads
- Fix terminateWithCause else-branch: log and ignore instead of
  re-dispatching HungUp when already disconnected
- Add terminatedCallIds set in ConnectionManager to track calls ended
  via ConnectionNotFound path; used by isConnectionDisconnected()
- Add pendingAnswers deferred-answer mechanism in ConnectionManager
  (reserveAnswer/consumeAnswer) to handle answerCall arriving before
  onCreateIncomingConnection fires
- Add isPending() and drainUnconnectedPendingCallIds() to ConnectionManager
- Guard onCreateIncomingConnection with isPending() check to reject
  stale Telecom callbacks from previous session
- Apply deferred answer in onCreateIncomingConnection via consumeAnswer()
- Fix onCreateIncomingConnectionFailed to dispatch HungUp for genuinely
  pending calls rejected by Telecom (e.g. BUSY), so Flutter cleans up
- Simplify handleTearDown() to only sync sensor state; remove
  cleanConnections() call that was corrupting next session's pending state
- Add markTerminated() call in executeOnConnection when ConnectionNotFound
- Rewrite ForegroundService.tearDown(): direct performEndCall + hungUp
  per connection, drain unconnected pending IDs, then cleanConnections()
- Add directNotifiedCallIds in ForegroundService to suppress stale async
  HungUp broadcasts arriving in next session after tearDown
- Update answerCall() to use deferred-answer path when connection is
  still pending (not yet created by Telecom)
- Add isConnectionDisconnected check in endCall() to return
  UNKNOWN_CALL_UUID for already-terminated calls
- Fix updateModernAudioState() null safety: guard on currentCallEndpoint
- Add isPending check in IncomingCallHandler before proceeding
- Update stress integration tests: add timeout to tearDown, fix last
  test assertion for Android ForegroundService lifecycle

* refactor(android): use framework state instead of custom terminated flag

Remove @Volatile terminated field and isTerminated property from
PhoneConnection - they duplicated state already tracked by the Telecom
framework's own Connection.state.

ConnectionManager.isConnectionDisconnected() now checks
state == STATE_DISCONNECTED under connectionResourceLock, consistent
with the existing approach of using the framework as single source
of truth (established in the terminateWithCause refactor).

* test(android): add ConnectionManager and PhoneConnectionServiceDispatcher tests

ConnectionManagerTest (17 tests): storage, duplicate guard, state queries
(isExistsIncomingConnection, isConnectionDisconnected, getConnections,
getActiveConnection, isConnectionAnswered, cleanConnections) and
validateConnectionAddition success/error paths.

PhoneConnectionServiceDispatcherTest (15 tests): each ServiceAction is routed to
the correct PhoneConnection method (hungUp, declineCall, onAnswer, changeMuteState);
missing connection produces ConnectionNotFound fallback; TearDown and
ServiceDestroyed call hungUp on every active connection and skip disconnected ones.

* test(android): fix TearDown tests to match actual handleTearDown behaviour

handleTearDown() only syncs sensor state -- it does not call hungUp() on
connections. Connection cleanup is done synchronously by ForegroundService
before the TearDown intent is sent. Updated three tests that were asserting
the old (removed) cleanup logic and replaced them with the correct contract.

* fix(android): plug pending/answer/directNotified leaks in connection lifecycle

#2 + #3: onCreateIncomingConnection failure paths (already-exists, busy) now call
removePending + consumeAnswer before returning a failed Connection. Returning
createFailedConnection does NOT trigger onCreateIncomingConnectionFailed, so
cleanup must happen in-place to prevent the pending reservation and any deferred
answer from leaking into the next session.

#4: executeOnConnection (connection not found) now calls removePending +
consumeAnswer alongside markTerminated. If HungUpCall/DeclineCall arrives before
onCreateIncomingConnection fires, the callId is still pending. Without clearing
it, Telecom can later call onCreateIncomingConnection, pass the isPending gate,
and create a zombie connection for a call already treated as ended.

#5: directNotifiedCallIds is cleared at the start of tearDown() to evict stale
entries from previous sessions. If the matching HungUp broadcast never arrived
the entry would linger indefinitely and could suppress a legitimate broadcast
if the same callId were reused in a future session.

#6: startIncomingCall now wraps addNewIncomingCall in try/catch and rolls back
the pending reservation via removePending on exception (e.g. SecurityException,
IllegalArgumentException), preventing permanent CALL_ID_ALREADY_EXISTS rejection
for that callId on the next attempt.

* fix(test): retry setUp until ForegroundService is bound in integration test

PHostApi Pigeon channel is only registered after onServiceConnected
fires, which is async. On the very first test setUp() may arrive
before the service is bound, causing a channel-error for both
onDelegateSet and setUp calls.

Retry setUp with 300 ms backoff (up to 10 attempts) to absorb the
binding delay. Move setDelegate after setUp so the unawaited
onDelegateSet call cannot produce an unhandled Future error.

* fix(android): call removePending after addConnection in onCreateIncomingConnection (#165)

When onCreateIncomingConnection succeeded, the callId was added to the
connections map but never removed from pendingCallIds. As a result, when
the main-process CallBloc called reportNewIncomingCall ~6 s after the push
isolate already answered the call, checkAndReservePending hit the
pendingCallIds branch first and returned CALL_ID_ALREADY_EXISTS instead of
CALL_ID_ALREADY_EXISTS_AND_ANSWERED. Flutter cannot distinguish the two
codes and the in-call UI was never shown.

Fix: remove the callId from pendingCallIds immediately after addConnection
in the success path of onCreateIncomingConnection.

Add unit tests (ConnectionManagerTest) that document both the fixed and the
broken behaviour, plus pending-set lifecycle tests.

Add an integration test (callkeep_stress_test) that exercises the full
push-answer then second-reportNewIncomingCall flow end-to-end on device.

* fix(android): send SIP BYE before closing WebSocket on call decline (#166)

* fix(android): send SIP BYE before closing WebSocket on call decline

When the user declined a call from the lock screen, the WebSocket was
closed before the SIP BYE reached the server. Two concurrent paths raced:

1. PhoneConnection.onDisconnect -> cancelIncomingNotification ->
   IC_RELEASE_WITH_DECLINE intent -> handleRelease -> release() ->
   releaseResources -> WebSocket closed immediately.

2. ConnectionPerform.DeclineCall broadcast ->
   connectionServicePerformReceiver -> performEndCall (SIP BYE) ->
   release() -- but WebSocket was already gone by the time BYE fired.

Fix: consolidate decline teardown into a single path.
- handleRelease(answered=false) now calls performEndCall first; its
  onSuccess/onFailure callbacks call release(), so the WebSocket is
  closed only after the BYE completes or fails.
- Remove DeclineCall/HungUp from connectionServicePerformReceiver to
  eliminate the duplicate path that caused the race.
- The 2-second stopTimeoutRunnable remains as a safety net if the
  Flutter isolate does not respond.
- handleRelease(answered=true) is unchanged: release() immediately,
  as the background isolate is no longer needed for active-call signaling.

* test(android): add CallLifecycleHandlerTest for decline teardown ordering

Covers the fix that ensures performEndCall (SIP BYE) fires before
release() triggers releaseResources (WebSocket teardown).

Tests:
- performEndCall emits events in order: performEndCall then releaseResources
- ordering holds on both success and failure callbacks
- correct callId is passed through to flutterApi
- releaseResources fires exactly once in both success and failure cases
- release() (answered path) skips performEndCall and goes directly to releaseResources
- null flutterApi does not crash (timeout path)
- null flutterApi in release() falls back to stopService

* fix(android): fall back to release() in performEndCall when flutterApi is null

When flutterApi is null, the safe-call in performEndCall was a no-op,
meaning release() was never called and resources were never cleaned up.
Now explicitly call release() when flutterApi is null so cleanup always
runs regardless of isolate state.

Update test to assert stopService() is triggered via release() in the
null-api path, and fix the IncomingCallService comment to reflect the
updated fallback behaviour.

* test(integration): cover call lifecycle regression scenarios in stress tests (#167)

* test(integration): cover decline-unanswered call path in stress tests

Three new integration tests for the fix in
IncomingCallService.handleRelease(answered=false):

1. decline unanswered - performEndCall fires, performAnswerCall does not
   Verifies the correct callback is triggered and no spurious answer
   callback leaks through the decline path.

2. immediate decline (no delay) still fires performEndCall
   Calls endCall with no delay after reportNewIncomingCall, simulating
   the lock-screen decline button race window. Confirms performEndCall
   fires even in the tightest timing.

3. after decline, re-reporting same ID returns callIdAlreadyTerminated
   Verifies the full cleanup chain completed: performEndCall -> release
   -> releaseResources -> ConnectionManager terminated set updated.

* fix(test): harden three flaky assertions in integration stress tests

1. Wire onPerformAnswerCall to fail() immediately so a late callback
   is never silently missed by the isEmpty check that follows.

2. Attach .catchError to the unawaited reportNewIncomingCall Future so a
   transient channel error cannot become an unhandled async exception.

3. Replace fixed Future.delayed(300ms) with a poll loop (100ms interval,
   5s timeout) that exits as soon as callIdAlreadyTerminated is returned,
   making the assertion deterministic on slow devices.

* fix(test): return typed null from catchError to satisfy analyzer

* fix(example): set ndkVersion to 28.2.13676358 to match integration_test (#168)

integration_test requires NDK 28.2.13676358 but the project was using
27.0.12077973. NDK versions are backward compatible, so pinning to the
highest required version silences the build warning.

* fix(android): resolve endCall/endAllCalls callbacks after broadcast confirmation (#158)

* fix(android): resolve endCall/endAllCalls callbacks after broadcast confirmation

Both methods previously called callback(Result.success) immediately after
firing a startService intent, so Dart believed teardown succeeded even if
the connection was already gone or startService failed.

endCall: register a one-shot BroadcastReceiver for HungUp/DeclineCall
filtered by callId; resolve the callback only when the matching broadcast
arrives from PhoneConnectionService. A 5-second timeout guards against a
broadcast that never arrives.

endAllCalls: snapshot active connections via connectionManager; if none are
tracked resolve immediately; otherwise count down each HungUp/DeclineCall
broadcast until all connections confirm teardown, with the same 5-second
safety timeout.

AtomicBoolean guarantees the callback is invoked exactly once even when
the broadcast and the timeout race each other.

* fix(android): register endCall/endAllCalls receivers as NOT_EXPORTED

Internal confirmation broadcasts must not be reachable by other apps.
Add exported param to registerReceiverCompat and registerConnectionPerformReceiver,
and pass false for the one-shot receivers in endCall and endAllCalls.

* fix(android): catch only IllegalArgumentException in finish() unregister guard

Catching generic Exception hides real programming errors and complicates
debugging receiver leaks. IllegalArgumentException is the only expected
failure (already-unregistered receiver), and finish() is already
guarded by AtomicBoolean against double invocation.

* fix(android): track pending callIds in endAllCalls to ignore unrelated broadcasts

Replace AtomicInteger counter with a synchronized Set of callIds snapshotted
from active connections. The receiver now ignores broadcasts without a callId
or with an unknown callId, and counts each id down exactly once, preventing
premature callback resolution from unrelated or duplicate HungUp broadcasts.

* test(android): add Robolectric tests for endCall broadcast and timeout logic

Cover: matching HungUp/DeclineCall resolves callback, non-matching callId
is ignored, and the 5s timeout resolves when no broadcast arrives.

* test(android): add Robolectric tests for endAllCalls broadcast and timeout logic

Cover: all active callIds confirmed resolves callback, partial confirmation
does not resolve early, unrelated/missing callId broadcasts are ignored,
and the 5s timeout resolves when confirmations are missing.

* test(integration): cover endCall/tearDown callback timing guarantees

Regression group for SignalingIsolateService.endCall and endAllCalls:
both methods now wait for broadcast confirmation before resolving the
Dart Future instead of immediately returning (fire-and-forget).

Tests added:
- tearDown resolves only after all performEndCall callbacks fired
  (no Future.delayed -- definitive regression test for synchronous contract)
- tearDown with no active calls does not fire performEndCall
- tearDown fires performEndCall exactly once per call
- endCall future always resolves within timeout (5s safety net)
- tearDown callback count equals number of active calls

Also removes the artificial Future.delayed(500ms) from the existing
'tearDown while calls are active' test -- ForegroundService.tearDown
fires synchronously so no delay is needed.

* test(integration): fix async timing and stale-callback reliability in stress tests

- Prevent double-tearDown contamination: tests that call tearDown() themselves
  set _globalTearDownNeeded = false so the global fixture skips the second call,
  which was re-firing performEndCall for already-ended calls and leaking stale
  Pigeon messages onto the next test's delegate.

- Add 300ms post-tearDown drain in the global tearDown fixture so any async
  Pigeon performEndCall messages from ForegroundService.tearDown arrive on the
  null delegate rather than the next test's delegate.

- Replace direct inline assertions with Completer-based latches: Pigeon delivers
  performEndCall callbacks asynchronously even when Kotlin fires them
  synchronously, so assertions after await tearDown() can fail. Latches wait up
  to 10 s for each expected callback.

- Filter onPerformEndCall handlers by expected call IDs so stale callbacks from
  earlier tests do not prematurely complete latches or inflate counts.

- Filter endCompleter in decline regression test by expected call ID to prevent
  a stale callback completing the completer with the wrong ID.

- Simplify regression tearDown tests to a single call to avoid Telecom state
  accumulation that prevents a second concurrent call from fully registering
  before tearDown scans connections. The multi-call tearDown property is covered
  by the stress group which runs early before state accumulates.

* style: rename _globalTearDownNeeded to satisfy no_leading_underscores_for_local_identifiers

* feat(android): add incomingCallFullScreen flag and fix nil-path clearing in StorageDelegate (#157)

* feat(android): add incomingCallFullScreen flag and fix nil-path clearing in StorageDelegate

StorageDelegate.Sound changes:
- Add INCOMING_CALL_FULL_SCREEN key with setIncomingCallFullScreen() /
  isIncomingCallFullScreen() (defaults to true).
- Fix initRingtonePath() / initRingbackPath(): passing null now removes the
  stored key instead of silently returning -- callers can clear the path.

Add StorageDelegateSoundTest covering set/get/toggle for the new flag,
null-clear behaviour for ringtone and ringback paths, and key independence.

* fix(android): guard null sound options in setUp and improve test isolation

- Skip initRingtonePath/initRingbackPath in ForegroundService.setUp() when
  the option value is null, so a setUp() call without explicit sounds does
  not erase a previously persisted custom path in SharedPreferences.
- Clear SharedPreferences in @Before setUp() in StorageDelegateSoundTest so
  tests are fully independent of execution order.
- Add default-value test: isIncomingCallFullScreen returns true when key is absent.

* test(integration): add call scenarios and background services integration tests (#169)

* test(integration): add call scenarios and background services integration tests

Add two integration test suites for the Android callkeep layer:

callkeep_call_scenarios_test.dart (30 tests):
- Incoming call: answer, decline, remote end, hold/unhold, mute/unmute, DTMF
- Outgoing call lifecycle (Android): startCall, connect, early end
- Two simultaneous calls with explicit hold
- reportUpdateCall, tearDown cleanup, operations on unknown callIds

callkeep_background_services_test.dart (8 pass / 16 skip):
- Push notification bootstrap path: deduplication, endCall, endCalls,
  callIdAlreadyExistsAndAnswered, callIdAlreadyTerminated, tearDown cleanup
- Signaling/lifecycle/cross-service groups skipped: SignalingIsolateService
  cannot start in an integration-test process because Flutter engine init
  exceeds Android's 5-second startForeground() deadline.

* test(integration): fix review comments in call scenarios test

- Gate setAudioDevice test to Android only (iOS does not implement
  setAudioDevice and would throw UnimplementedError)
- Replace containsAll with equals for DTMF ordering assertion to
  verify both sequence and exact match

* fix(android): guard full-screen intent behind isIncomingCallFullScreen option (#170)

* fix(android): guard full-screen intent behind isIncomingCallFullScreen option

setFullScreenIntent was unconditional -- the full-screen incoming-call UI
always launched even when the app was configured to suppress it.

Wrap setFullScreenIntent in an isIncomingCallFullScreen(context) check so
the option stored in StorageDelegate.Sound controls the behaviour.

* fix(android): move isFullScreen option from StorageDelegate.Sound to StorageDelegate.IncomingCall

isIncomingCallFullScreen is a notification display preference, not a sound
setting. Moving it to a dedicated StorageDelegate.IncomingCall object keeps
Sound focused on ringtone/ringback paths only.

Rename setIncomingCallFullScreen/isIncomingCallFullScreen to setFullScreen/
isFullScreen for brevity -- the enclosing object already carries the context.

Update IncomingCallNotificationBuilder to reference the new location.
Move the fullScreen tests from StorageDelegateSoundTest to a new
StorageDelegateIncomingCallTest.

* feat(android): use ActivityManager to detect PhoneConnectionService state in CallDiagnostics (#171)

* feat(android): use ActivityManager to detect PhoneConnectionService state in CallDiagnostics

PhoneConnectionService.isRunning is a JVM-static companion field that is
always false when read from the main process once the service runs in a
separate :callkeep_core process. Replace it with an ActivityManager
.getRunningServices() query so the diagnostic correctly reflects the service
state across process boundaries.

* fix(android): address review comments in CallDiagnostics

- Reword comment on PhoneConnectionService detection to not claim
  process isolation is already in place (PR-9b not yet merged)
- Match both className and packageName in isServiceRunning() to
  prevent false positives from other apps with the same class name

* refactor(android): remove outgoing call retry logic (#172)

* refactor(android): remove outgoing call retry logic

RetryManager and CallPhoneSecurityRetryDecider retried startOutgoingCall()
only on SecurityException("CALL_PHONE permission required"). However,
TelecomManager.placeCall() skips the CALL_PHONE check entirely for
self-managed PhoneAccounts, so this exception cannot occur in practice.
The actual race condition (PhoneAccount not yet processed by Telecom)
produces a different message that the filter never matched, making
the retry dead code.

- Delete RetryManager.kt and RetryDecider/RetryConfig interfaces
- Remove isCallPhoneSecurityException() extension from Extensions.kt
- Remove CallPhoneSecurityRetryDecider from ForegroundService
- Simplify startCall(): single attempt, direct error mapping
- Fix missing StorageDelegate import in IncomingCallNotificationBuilder

* fix(android): address review comments in startCall refactor

- Add OutgoingFailureSource.DISPATCH_ERROR for synchronous failures
  thrown by startOutgoingCall() before reaching ConnectionService;
  CS_CALLBACK is now used only for async Telecom-reported failures
- Update callback comment to list all invocation paths: synchronous
  dispatch error, async CS success/failure, and timeout

* fix(android): remove SharedPreferences caching from StorageDelegate (#173)

* fix(android): remove SharedPreferences caching from StorageDelegate

The cached sharedPreferences field was a JVM-static singleton that
persisted across test runs. When Robolectric recreates the Application
between tests, the cached instance diverges from the new context prefs
causing reads to return stale values and flaky test failures.

Replace the cached field with a plain function that resolves
SharedPreferences fresh on every call via context.applicationContext.
applicationContext is always non-null so all nullable guards are removed.
Stale null-coalescing throws on getLong() (which never returns null)
are also removed.

* test(android): remove manual SharedPreferences clear from StorageDelegate tests

StorageDelegate no longer caches SharedPreferences in a static field,
so inter-test state leakage cannot occur. The manual clear() workaround
in setUp() is no longer needed.

* refactor(android): address review comments in StorageDelegate

- Replace apply { ... apply() } scope ambiguity with explicit chaining
  (.putXxx().apply()) for single-value setters; use .also { }.apply()
  for conditional put/remove setters
- Remove redundant == true from isLaunchBackgroundIsolateEvenIfAppIsOpen
  and isSignalingServiceEnabled: getBoolean() returns non-null Boolean

* test(android): add CallkeepAndroidOptions Dart unit tests (#174)

* refactor(android): split ConnectionPerform enum into CallLifecycleEvent and CallMediaEvent (#176)

* refactor(android): split ConnectionPerform enum into CallLifecycleEvent and CallMediaEvent

Replace the single ConnectionPerform enum with two focused enums backed by a
sealed interface ConnectionEvent:

- CallLifecycleEvent: call state transitions (AnswerCall, DeclineCall, HungUp,
  OngoingCall, DidPushIncomingCall, OutgoingFailure, IncomingFailure, ConnectionNotFound)
- CallMediaEvent: audio/media control events (AudioMuting, AudioDeviceSet,
  AudioDevicesUpdate, SentDTMF, ConnectionHolding)

ConnectionServicePerformBroadcaster.registerConnectionPerformReceiver and
DispatchHandle.dispatch now accept List<ConnectionEvent> and ConnectionEvent
respectively, keeping the broadcast transport layer generic.

All call sites updated: PhoneConnection, PhoneConnectionService,
PhoneConnectionServiceDispatcher, ForegroundService, IncomingCallService,
SignalingIsolateService, and their corresponding tests.

* fix(android): make ConnectionEvent list type explicit in ForegroundService

Replaces `CallLifecycleEvent.entries + CallMediaEvent.entries` with a
typed `buildList<ConnectionEvent>` to avoid relying on implicit type
inference which could resolve T as CallLifecycleEvent and reject
CallMediaEvent entries.

* refactor(android): replace OutgoingCallbacksManager with per-call BroadcastReceiver in startCall (#175)

* refactor(android): replace OutgoingCallbacksManager with per-call BroadcastReceiver in startCall

OutgoingCallbacksManager held a shared map of callId -> callback + timeout.
The race: when timeout fired, the callback was removed from the map, but
handleCSReportOngoingCall still called performStartCall unconditionally --
Flutter received TIMEOUT then a spurious "call active" event.

Replace with a self-contained per-call pattern (matching SignalingService):
- Register a local BroadcastReceiver for OngoingCall + OutgoingFailure
- AtomicBoolean resolved guarantees exactly-once callback invocation
- performStartCall lives inside the receiver's onReceive, so it is
  naturally guarded: if timeout fires first, the receiver is unregistered
  and the late CS broadcast is silently dropped
- finish() cancels the timeout and unregisters the receiver atomically

Remove OutgoingCallbacksManager class, outgoingCallbacksManager field,
handleCSReportOngoingCall, handleCSReportOutgoingFailure from the global
receiver switch. OUTGOING_CALL_TIMEOUT_MS moved to companion object.

* fix(android): address review comments in ForegroundService startCall

- Narrow global receiver registration to exclude OngoingCall and
  OutgoingFailure: those actions are now owned by the per-call receiver
  created in startCall() -- including them in the global registration
  caused duplicate delivery and unnecessary wake-ups
- Add pendingCallCleanups set to track active per-call receivers;
  onDestroy() iterates the set and cancels all pending timeouts and
  unregisters all per-call receivers, preventing resource leaks when
  the service is destroyed while an outgoing call is still in progress

* fix(android): address review comments in ForegroundService startCall (round 2)

- Fix 1: restrict global receiver registration to only the events actually
  handled by connectionServicePerformReceiver -- exclude IncomingFailure and
  ConnectionNotFound which were registered but never processed, causing
  unnecessary wakeups and log noise

- Fix 2: replace lateinit var receiver with nullable var so that cancelResources()
  can safely guard with receiver?.let when called before the receiver is
  registered (narrow window between pendingCallCleanupsByCallId.put and
  registerConnectionPerformReceiver)

- Fix 3: check resolved.get() at the top of onReceive before executing any
  side effects (performStartCall, saveFailedOutgoingCall) so that stale
  broadcasts arriving after a timeout cannot trigger them

- Fix 4: replace pendingCallCleanups (Set) with pendingCallCleanupsByCallId
  (ConcurrentHashMap<String, () -> Unit>); cancel the previous entry when
  startCall() is invoked again with the same callId to prevent duplicate
  receivers and timeouts accumulating for a reused callId

* fix(android): remove duplicate answer signal in performAnswerCall (#177)

* fix(android): remove duplicate answer signal in performAnswerCall

CallLifecycleHandler.performAnswerCall() called connectionController.answer()
inside the Flutter onSuccess callback. Telecom already confirmed the answer
by invoking performAnswerCall; calling answer() again sent a duplicate signal
to the connection service, which could produce a double-answer state or a
redundant notification.

Remove the duplicate call and replace it with a log. The failure path still
calls connectionController.tearDown() to clean up the connection on error.

Tests: added FakeConnectionController (avoids Mockito/Kotlin nullability issues)
and three new cases in CallLifecycleHandlerTest covering the no-duplicate-answer
invariant, the Flutter acknowledgement path, and the tearDown-on-failure path.

* fix(android): address review comments on performAnswerCall

- Add explicit null guard for flutterApi in performAnswerCall with a warning log,
  consistent with performEndCall; the method is now a documented no-op when the
  Flutter isolate is not yet attached
- Strengthen no-duplicate-answer test: assert performAnswer was forwarded to Flutter
  before checking answerCallCount == 0, so the assertion cannot pass trivially when
  executeIfBackground is a no-op
- Force SignalingStatus.DISCONNECT in setUp to pin IsolateSelector to BACKGROUND,
  preventing order-dependent failures if another test class leaves the broadcaster
  in a MAIN state
- Add null-flutterApi test for performAnswerCall graceful degradation

* feat(android): expose incomingCallFullScreen option via Pigeon API (#178)

* feat(android): expose incomingCallFullScreen option via Pigeon API

Add incomingCallFullScreen: bool? to PAndroidOptions in the Pigeon
message definition and regenerate all generated files (Generated.kt,
callkeep.pigeon.dart, test_callkeep.pigeon.dart).

Wire the new field in ForegroundService.setUp(): when the caller sets
incomingCallFullScreen, it is persisted via StorageDelegate.IncomingCall
.setFullScreen(). Null means "leave the previously persisted value as-is".

Add incomingCallFullScreen to CallkeepAndroidOptions in
webtrit_callkeep_platform_interface with null default (unspecified).
Update CallkeepAndroidOptionsConverter.toPigeon() to forward the field.

Update callkeep_android_options_test.dart with new tests for default
value, field assignment, equality, and toPigeon() mapping.

* fix(android): update setUp() comment and log to reflect all Android options

* feat(android): introduce MainProcessConnectionTracker as connection state mirror (#179)

* feat(android): introduce MainProcessConnectionTracker as connection state mirror

Add MainProcessConnectionTracker to shadow PhoneConnectionService connection
state in the main process, updated from existing broadcast events.

ForegroundService and ConnectionsApi now read connection state from the
tracker instead of calling PhoneConnectionService.connectionManager directly.
This eliminates all direct cross-process state reads from the main process
side, making the architecture ready for the process split (PR-9b) where
PhoneConnectionService will run in a separate :callkeep_core process.

Changes:
- MainProcessConnectionTracker: tracks connections, pending calls, answered
  state, terminated state, deferred answers, and Pigeon connection states
- ForegroundService: wires tracker updates from DidPushIncomingCall,
  AnswerCall, HungUp/DeclineCall, and OngoingCall broadcasts; replaces
  connectionManager reads in answerCall, endCall, and tearDown
- ConnectionsApi: getConnection/getConnections/cleanConnections now read
  from ForegroundService.tracker
- tearDown keeps PhoneConnection.hungUp() via CS for native cleanup with
  a TODO(PR-9b) marking the remaining cross-process access
- 28 unit tests covering the full call lifecycle in the tracker

* fix(android): roll back stale pending entry and add CS fallback in answerCall

- Add MainProcessConnectionTracker.removePending() to undo the addPending()
  call when PhoneConnectionService rejects a reportNewIncomingCall (e.g.
  callIdAlreadyExistsAndAnswered). Without this, drainUnconnectedPendingCallIds()
  would fire a spurious performEndCall in the next tearDown() for a call that
  Telecom never accepted.

- In ForegroundService.answerCall(), add a fallback to
  PhoneConnectionService.connectionManager when the tracker does not yet know
  about a call. sendBroadcast() is async, so the DidPushIncomingCall event from
  a push-path call can arrive after answerCall() is already executing. The CS
  connectionManager is populated synchronously when the PhoneConnection is
  created, so it serves as the authoritative fallback for this window.
  TODO(PR-9b): replace with IPC lookup when the :callkeep_core process split lands.

- Add three unit tests for removePending in MainProcessConnectionTrackerTest.

* fix(android): fix answerCall race and tearDown double performEndCall

answerCall: check CS connectionManager before tracker.isPending so that
calls which are still pending in the tracker (DidPushIncomingCall not yet
delivered) but already have a live PhoneConnection in CS are answered
immediately instead of going through the deferred-answer path.

tearDown: add unconnectedPending callIds to directNotifiedCallIds before
firing performEndCall so that the async HungUp broadcast from
connection.hungUp() (Step 5) is suppressed when the deferred-answer path
caused CS to create a PhoneConnection via reserveAnswer.

Add integration tests covering the ForegroundService main-process
signaling path: answerCall timing, endCall, tearDown, and deduplication.

* refactor(android): remove unused metadata param from addPending

* fix(android): markTerminated clears deferred answer reservation

* fix(android): reset stale lifecycle state on callId reuse in addPending and promote

* fix(android): remove pending tracker entry on startCall failure and timeout

* fix(android): consume deferred answer reservation in handleCSReportAnswerCall

* fix(android): track hold state in tracker so getConnections returns STATE_HOLDING

* fix(android): addPending returns bool to guard rollback in reportNewIncomingCall duplicate race

* refactor(android): extract ConnectionTracker interface, move singleton to MainProcessConnectionTracker (#180)

* refactor(android): extract ConnectionTracker interface, move singleton to MainProcessConnectionTracker

Introduce `ConnectionTracker` interface that declares all read/write operations
previously defined directly on `MainProcessConnectionTracker`.

`MainProcessConnectionTracker` now:
- implements `ConnectionTracker`
- exposes a `companion object { val instance: ConnectionTracker }` singleton

`ForegroundService.tracker` becomes a delegating property that forwards to
`MainProcessConnectionTracker.instance`, keeping internal call sites unchanged.

`ConnectionsApi` is updated to reference `MainProcessConnectionTracker.instance`
directly, removing its dependency on `ForegroundService` for tracker access.

This decouples the tracker lifecycle from `ForegroundService` and prepares the
interface boundary for the PR-9b process split: swapping in a broadcast-backed
implementation will require only changing the `instance` assignment.

* fix(android): address review comments on ConnectionTracker refactor

- Merge duplicate KDoc blocks on addPending into a single comment
- Make MainProcessConnectionTracker constructor internal to prevent
  accidental multi-instance creation outside tests
- Store tracker as ConnectionTracker field in ConnectionsApi to
  depend on the abstraction rather than the concrete class name

* refactor(android): replace remaining direct connectionManager reads with tracker (#181)

* refactor(android): replace direct connectionManager reads with tracker in SignalingIsolateService and WebtritCallkeepPlugin

SignalingIsolateService.endAllCalls() and WebtritCallkeepPlugin.onStateChanged()
both read active connections from PhoneConnectionService.connectionManager directly.
Replace with MainProcessConnectionTracker.instance.getAll() so these call sites
no longer cross into the :callkeep_core process boundary after the process split.

Both sites only need the list of active CallMetadata (callId, isEmpty, size) --
no live PhoneConnection object access -- making this a safe read-only substitution.

Removes the now-unused PhoneConnectionService import from WebtritCallkeepPlugin.

Part of PR-9b migration steps 1 and 2.

* fix(android): cover broadcast-lag window in tracker reads for endAllCalls and onStateChanged

Add getPendingCallIds(): Set<String> to ConnectionTracker interface and
MainProcessConnectionTracker. Non-destructive snapshot of pending call IDs,
complementing the existing drainUnconnectedPendingCallIds() destructive drain.

SignalingIsolateService.endAllCalls(): union promoted (getAll) and pending
(getPendingCallIds) call IDs when building the HungUp confirmation set. Without
this, a call in the broadcast-lag window (PhoneConnection created in CS but
DidPushIncomingCall not yet processed by ForegroundService) would cause
endAllCalls to resolve immediately with an empty set, skipping the countdown.
The 5-second timeout handles any pending IDs for which CS never sends HungUp.

WebtritCallkeepPlugin.onStateChanged(): check both promoted and pending calls
when setting lock-screen/turn-screen-on flags on ON_START. Without the pending
check, a call arriving during ON_START would leave the flags cleared.

Add four unit tests for getPendingCallIds covering: returns pending IDs, excludes
promoted, is non-destructive, and empty after clear.

* feat(android): add CallCommandEvent IPC and wire TearDownConnections, ReserveAnswer, CleanConnections (#182)

* feat(android): add CallCommandEvent IPC and wire TearDownConnections, ReserveAnswer, CleanConnections

Add CallCommandEvent enum (TearDownConnections, TearDownComplete, ReserveAnswer,
CleanConnections) to ConnectionServicePerformBroadcaster. These are commands sent
from the main process to :callkeep_core, distinct from the existing report events
(CallLifecycleEvent, CallMediaEvent) that flow in the opposite direction.

PhoneConnectionService registers a BroadcastReceiver in onCreate() for the three
incoming commands:
- TearDownConnections: hungUp() on all PhoneConnections + cleanConnections() + sends TearDownComplete ack
- ReserveAnswer(callId): connectionManager.reserveAnswer(callId)
- CleanConnections: connectionManager.cleanConnections()

ForegroundService.tearDown() replaces direct connectionManager.getConnections() +
connection.hungUp() + cleanConnections() with a sendTearDownConnections() broadcast.
Registers a one-shot TearDownComplete listener (3 s safety timeout) before proceeding
to tracker.clear() and the keepalive tearDown intent.

ForegroundService.answerCall() deferred path replaces direct
connectionManager.reserveAnswer(callId) with sendReserveAnswer() broadcast.

ConnectionsApi.cleanConnections() replaces direct connectionManager.cleanConnections()
with sendCleanConnections() broadcast.

All three callers now contain no direct connectionManager.* access. The change is
transparent in the current single-process setup and prepares the IPC boundary for
the :callkeep_core process split (PR-9b steps 5, 6, 7, 8).

* fix(android): switch IPC commands to startService and fix tearDown receiver lifecycle

- Switch Main->CS commands (TearDownConnections, ReserveAnswer,
  CleanConnections) from broadcasts to startService intents.
  Explicit intents cannot be sent by external apps, eliminating the
  security exposure on API<33 where exported=false is ignored for
  broadcast receivers. As a side-effect, startService queues intents
  to onStartCommand after onCreate completes, so ReserveAnswer can no
  longer be dropped if PhoneConnectionService is still starting up.

- Add TearDownConnections, ReserveAnswer, CleanConnections to
  ServiceAction enum and handle them in onStartCommand before routing
  to PhoneConnectionServiceDispatcher. Remove commandReceiver
  (BroadcastReceiver) from PhoneConnectionService.

- Store tearDownAckReceiver and tearDownTimeoutRunnable as fields in
  ForegroundService. Cancel and unregister both in onDestroy() so the
  receiver cannot outlive the service and invoke a stale callback if
  the service is destroyed before TearDownComplete arrives.

* refactor(android): introduce CallkeepCore facade for all :callkeep_core interactions (#183)

* refactor(android): introduce CallkeepCore facade for all :callkeep_core interactions

Add CallkeepCore interface and InProcessCallkeepCore implementation that
unify the two previously separate access points to :callkeep_core:

- ConnectionTracker (read/write shadow state in the main process)
- PhoneConnectionService static methods (commands to :callkeep_core)

CallkeepCore groups methods into three sections:
- State queries: exists, isPending, isTerminated, isAnswered, getAll,
  getPendingCallIds, get, getState, toPCallkeepConnection
- State mutations: addPending, removePending, promote, markAnswered,
  markHeld, markTerminated, reserveAnswer, consumeAnswer,
  drainUnconnectedPendingCallIds, clear
- CS commands: startOutgoingCall, startIncomingCall, startAnswerCall,
  startDeclineCall, startHungUpCall, startEstablishCall, startUpdateCall,
  startSendDtmfCall, startMutingCall, startHoldingCall, startSpeaker,
  setAudioDevice, tearDownService, sendTearDownConnections,
  sendReserveAnswer, sendCleanConnections

InProcessCallkeepCore holds a ConnectionTracker for state and reads the
context lazily from ContextHolder per call. The companion object singleton
is exposed via CallkeepCore.instance so call sites do not depend on
concrete types. After the process split, swap InProcessCallkeepCore for a
broadcast/binder-backed implementation by changing only that one assignment.

Update all call sites (ForegroundService, SignalingIsolateService,
ConnectionsApi, WebtritCallkeepPlugin) to use CallkeepCore.instance
instead of mixing tracker.* and PhoneConnectionService.* directly.

* fix(android): update stale tracker references in logs and comments after CallkeepCore facade

Replace all remaining mentions of "tracker" in log messages and inline
comments with "core shadow" to match the new CallkeepCore abstraction.
Also clarify the ContextHolder.context comment in InProcessCallkeepCore:
the property getter defers the read to call time (not construction),
but ContextHolder.init() must still have been called beforehand.

* feat(android): add consumer ProGuard/R8 keep rules for :callkeep_core IPC classes (#184)

* feat(android): add consumer ProGuard/R8 keep rules for :callkeep_core IPC classes

Add consumer-rules.pro with keep rules for PhoneConnectionService,
ConnectionManager, and PhoneConnection. Wire it via consumerProguardFiles
in build.gradle.

After the android:process=":callkeep_core" split these classes are resolved
by name at runtime via explicit startService/sendBroadcast intents.
R8 renaming them would silently break cross-process IPC.

* refactor(android): narrow ProGuard keep rule to -keepnames for PhoneConnectionService only

- Drop ConnectionManager and PhoneConnection keeps: they are not accessed
  via string-based reflection and R8 can safely shrink their members
- Switch PhoneConnectionService from -keep { *; } to -keepnames: the class
  name must survive for ComponentName-based startService() IPC but member
  shrinking is fine

* docs(android): document CallkeepCore as single IPC access point, forbid direct connectionManager calls (#185)

* docs(android): document CallkeepCore as single IPC access point, forbid direct connectionManager calls

Add CallkeepCore section to AGENTS.md:
- All main-process code must go through CallkeepCore.instance, not connectionManager directly
- Document CallCommandEvent IPC events table (TearDownConnections, TearDownComplete, ReserveAnswer, CleanConnections)
- Add invariant: never call connectionManager.* from main process

* docs(android): scope connectionManager rule to new code, fix IPC mechanism wording

- Clarify CallkeepCore rule as target state post-split, not current state
- Note that pre-existing violations carry TODO(PR-9b)
- Replace "local broadcasts" with accurate wording: app-scoped broadcasts
  (sendBroadcast with setPackage) and explicit startService intents

* feat(android): assign PhoneConnectionService to :callkeep_core OS process (#186)

Add android:process=":callkeep_core" to PhoneConnectionService in AndroidManifest.xml.
This physically moves the Telecom ConnectionService into a dedicated OS process,
completing the dual-process isolation. All IPC prerequisites (CallCommandEvent
broadcasts, MainProcessConnectionTracker, CallkeepCore facade) are already in place.

* fix(android): remove remaining direct connectionManager calls from main process (#187)

* fix(android): remove remaining direct connectionManager calls from main process

- answerCall(): remove broadcast-lag fallback (step 2) that read
  connectionManager.getConnection() -- after :callkeep_core split this
  always returned null; tracker + pending is authoritative
- onDelegateSet(): replace connectionManager.getConnections() with
  core.getAll(); trigger audio state resync via new SyncAudioState IPC
  command instead of calling forceUpdateAudioState() directly on
  PhoneConnection objects that live in another process
- CallDiagnostics: replace connectionManager.toString() with
  CallkeepCore.instance.getAll() and rename key to trackerState

New SyncAudioState ServiceAction wired through:
  PhoneConnectionEnums, PhoneConnectionService (handler + companion),
  PhoneConnectionServiceDispatcher (exhaustive when), CallkeepCore
  interface, InProcessCallkeepCore

* fix(android): register pending in startIncomingCall to cover all entry points

Push-driven paths (BackgroundPushNotificationIsolateBootstrapApi,
SignalingIsolateService, IncomingCallSmsTriggerReceiver) call
startIncomingCall() without calling addPending() first. After removing
the direct connectionManager fallback in answerCall(), these calls
would fall through to the "unknown call" error during the broadcast-lag
window.

Fix: add tracker.addPending() at the top of
InProcessCallkeepCore.startIncomingCall() so all entry points register
the pending ID before handing off to Telecom. addPending() is
idempotent so callers that already call it explicitly (ForegroundService)
are not affected.

* docs(android): update AGENTS.md to reflect completed :callkeep_core process split (#188)

- Remove "target state post-split" qualifier -- split is now active
- Remove references to TODO(PR-9b) violations -- all removed in #187
- Fix IPC description: app-scoped broadcasts + startService intents
  (not "local broadcasts via CommunicateServiceDispatcher")
- Add SyncAudioState to CallCommandEvent table
- Strengthen connectionManager invariant to absolute prohibition

* fix(android): initialize ContextHolder in PhoneConnectionService.onCreate() (#189)

In the :callkeep_core OS process PhoneConnectionService runs in isolation --
no Flutter engine, no WebtritCallkeepPlugin. ContextHolder.init() was never
called there, so the first onStartCommand() that reads ContextHolder.appUniqueKey
via ServiceAction.from() threw IllegalStateException and crashed the process.

Add ContextHolder.init(applicationContext) as the first statement in
PhoneConnectionService.onCreate() to cover the :callkeep_core process.
The call is idempotent -- harmless if the main process ever reaches onCreate first.

* refactor(android): remove ContextHolder dependency from ServiceAction (#190)

* refactor(android): remove ContextHolder dependency from ServiceAction

ServiceAction.action was built as:
  {packageName}_56B952..._{enumName}_connection_service

This required ContextHolder.appUniqueKey at runtime, which crashed the
:callkeep_core process because ContextHolder was never initialized there
(no Flutter engine, no WebtritCallkeepPlugin in that process).

The uniqueness mechanism is not needed:
- startService intents are explicit (component is set) -- the action is
  only a routing key inside onStartCommand, not an inter-app identifier.
- Broadcasts are already restricted by setPackage(packageName).

Replace with "callkeep_{name}" -- no context dependency, no global
singleton, no process-specific initialization required. Revert the
ContextHolder.init() workaround added in #189 as it is now unnecessary.

* refactor(android): remove appUniqueKey from NotificationAction and ForegroundCallServiceEnums

Same reasoning as ServiceAction: both enums are used only as routing keys
inside onStartCommand of explicit service intents. The packageName prefix
adds no uniqueness or security value there.

Replace with "callkeep_{name}" -- no ContextHolder dependency.

* refactor(android): remove appUniqueKey from ContextHolder

appUniqueKey is no longer referenced anywhere -- all three consumers
(ServiceAction, NotificationAction, ForegroundCallServiceEnums) were
simplified to use plain "callkeep_{name}" strings. Remove the property
and its private packageName backing field.

* fix(android): make ServiceAction.from() non-throwing, log unknown actions

from() previously threw IllegalArgumentException which was called before
the try/catch in onStartCommand -- an unrecognized action string would
propagate up and crash the service. Return null instead and let the
existing early-return path log and ignore the unknown action safely.

* fix(android): stabilize :callkeep_core process split - push freeze, call duplication, answerCall race (#191)

* fix(android): remove isRunning guard in IncomingCallService.release() to fix push notification freeze

After the :callkeep_core process split, PhoneConnection runs in the
:callkeep_core JVM where IncomingCallService.isRunning is always false.
The old guard silently dropped every cancel/dismiss request from
PhoneConnection, leaving the incoming-call notification frozen after the
user answered or declined.

Remove the guard and send the release intent unconditionally. If the
service is not running, startService either fails under background-start
restrictions (API 26+) or starts briefly, finds lastMetadata null in
handleRelease, and self-stops via the 2-second timeout - no visible
side effect.

* fix(android): prevent duplicate incoming call after process split via signalingRegistered guard

After the :callkeep_core process split the DidPushIncomingCall broadcast
arrives AFTER the Pigeon response (not before as in the old same-process
architecture). When the signaling path calls reportNewIncomingCall and
succeeds, the subsequent broadcast was unconditionally firing a second
didPushIncomingCall Pigeon event, causing Flutter to register the same
call twice.

Fix: introduce a signalingRegistered guard in MainProcessConnectionTracker.
ForegroundService marks a callId as signaling-registered on successful
reportNewIncomingCall; handleCSReportDidPushIncomingCall consumes the
mark and suppresses the redundant Pigeon event.

Also move the three callback guard sets (directNotified, endCallDispatched,
signalingRegistered) from ForegroundService local fields into
MainProcessConnectionTracker, exposing them via ConnectionTracker and
CallkeepCore so all main-process components share a single authoritative
state store without coupling ForegroundService internals.

* fix(android): eliminate answerCall TOCTOU race with synchronized connection operations

In the dual-process setup, answerCall() and onCreateIncomingConnection()
run on different threads (main binder thread vs Telecom binder thread).
The previous code checked connections[] and pendingAnswers[] separately,
leaving a window where both paths could observe an empty connections map
simultaneously: answerCall reserves a pending answer, then
onCreateIncomingConnection fires and misses it (or vice-versa), so the
call is never answered.

Introduce two synchronized methods on ConnectionManager:
- addConnectionAndConsumeAnswer: atomically adds the connection and
  drains any pending answer reservation in one synchronized block.
- reserveOrGetConnectionToAnswer: atomically either returns the existing
  connection for immediate answer, or reserves a deferred answer if the
  connection does not exist yet.

Both are synchronized on connectionResourceLock, eliminating the gap.

Also add NotifyPending ServiceAction: the main process sends this intent
before addNewIncomingCall so that :callkeep_core's ConnectionManager
registers the callId as pending before onCreateIncomingConnection fires,
enabling isPending() to return true during the broadcast-lag window.

* test: add integration test suites and sequential runner for :callkeep_core stability

Add regression integration tests covering bugs introduced by the
:callkeep_core process split:
- signaling-path incoming call must not duplicate via push broadcast
- push notification must not freeze after answer/decline
- answerCall idempotency (fires performAnswerCall exactly once)
- endCall / tearDown callback deduplication
- duplicate reportNewIncomingCall error codes
- answered/terminated call re-report error codes

Add new test files covering broader scenarios:
- callkeep_call_scenarios_test: extended call lifecycle, DTMF, hold/mute
- callkeep_client_scenarios_test: client-side API contracts
- callkeep_connections_test: CallkeepConnections state queries
- callkeep_delegate_edge_cases_test: delegate swap, audio session callbacks
- callkeep_lifecycle_test: setUp/tearDown status stream transitions
- callkeep_reportendcall_reasons_test: all six CallkeepEndCallReason values
- callkeep_state_machine_test: full state-machine sequences

Add run_integration_tests.sh: ru…
…n to info (#218)

In the push+signaling combined flow the push path registers the incoming
call first; the signaling WebSocket then calls reportNewIncomingCall with
the same callId a moment later. The tracker-level rejection is correct and
expected, not a warning condition.

Logging it at WARN caused consuming apps that forward WARN-level callkeep
logs to FirebaseCrashlytics.recordError() to receive spurious exception
reports for every incoming call received while the app was backgrounded.
…219)

* fix(android): initialize AssetCacheManager in :callkeep_core process

PhoneConnection.onShowIncomingCallUi() plays the custom ringtone by calling
AudioManager.startRingtone(metadata.ringtonePath), which resolves the asset
path via AssetCacheManager.getAsset(). AssetCacheManager was only initialized
in WebtritCallkeepPlugin (main process), so in the :callkeep_core process it
was always uninitialized, causing an IllegalStateException that was silently
caught and fell back to getDefaultRingtone() -- the system ringtone.

Fix: call AssetCacheManager.init() in PhoneConnectionService.onCreate(),
alongside the existing ContextHolder.init() that already handles the same
per-process initialization requirement.

* fix(android): clarify AssetCacheManager comment per review

The previous comment said getRingtone() throws IllegalStateException, but
getRingtone() actually catches the exception. It is AssetCacheManager.getAsset()
that throws, which getRingtone() then catches and falls back to the system ringtone.
…incoming calls (#217)

* fix(android): wake screen and show notification on lock screen for incoming calls

On MIUI/HyperOS USE_FULL_SCREEN_INTENT is denied by default, causing the
screen to stay dark when an incoming call arrives while the device is locked.

Two fixes:
- IncomingCallNotificationBuilder: set VISIBILITY_PUBLIC on every incoming
  call notification explicitly (channel-level visibility is not always
  inherited on MIUI); guard setFullScreenIntent with canUseFullScreenIntent()
  so the permission-denied case is handled cleanly.
- IncomingCallService: acquire SCREEN_BRIGHT_WAKE_LOCK|ACQUIRE_CAUSES_WAKEUP
  on IC_INITIALIZE as a fallback wake mechanism when full-screen intent is
  unavailable; release the lock in onDestroy() with a 30-second auto-timeout.

* fix(android): address review comments on PR #217

- buildSilent(): add explicit VISIBILITY_PUBLIC so the ongoing call
  notification stays visible on the lock screen on MIUI/HyperOS after
  the ringing phase ends.
- acquireScreenWakeLock(): rename to acquireScreenWakeLockIfNeeded() and
  gate it behind a check -- only acquire when the full-screen intent
  setting is enabled but the system permission is not available (true
  fallback behavior). Skip the lock on standard devices where full-screen
  intent is working.
- handleRelease(): release the wake lock immediately when the call leaves
  the ringing state (answer/decline) rather than waiting for onDestroy(),
  so the screen is not held on during post-call teardown for up to 30s.
  onDestroy() remains as a final safety net.
…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(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
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.

2 participants