Open
Conversation
- Extend PHostApi with onDelegateSet() and wire new channel - Implement ForegroundService.onDelegateSet to resync active connections - Add forceUpdateAudioState() in PhoneConnection to push audio state to Flutter
The `log` method checked for list emptiness synchronously but accessed the element asynchronously via `Handler.post`. If the `isolateDelegates` list was cleared before the Handler executed, a `NoSuchElementException` occurred. Replaced `.first()` with `.firstOrNull()?.` to safely handle cases where the list becomes empty during the thread switch context.
feat: add microphone checking for call
* feat: add comprehensive CallDiagnostics utility and failed-call reporting
* feat: wire diagnostics and runtime permission APIs to Flutter plugin
- Expose CallkeepDiagnostics via AndroidCallkeepUtils and new Dart
CallkeepDiagnostics helper that calls getDiagnosticReport() on the
platform interface.
- Add PHostDiagnosticsApi + DiagnosticsApi on Android that delegate to
CallDiagnostics.gatherMap(context), and register/unregister it in
WebtritCallkeepPlugin.
- Extend PHostPermissionsApi with generic requestPermissions() and
checkPermissionsStatus() methods, including:
- New PCallkeepPermission + PPermissionResult Pigeon types.
- PSpecialPermissionStatusTypeEnum.unknown state.
- Extensions to map between PCallkeepPermission, Android manifest
permission strings, and high-level CallkeepPermission enums.
- Implement requestPermissions() and checkPermissionsStatus() in
PermissionsApi using ActivityHolder and ContextCompat checks.
- Introduce CallkeepPermission and CallkeepSpecialPermissionStatus.unknown
on the platform interface, plus conversion helpers.
- Implement the new diagnostics + permission APIs in
WebtritCallkeepAndroid, returning strongly typed Maps to Flutter.
- Update generated Pigeon codecs and test stubs to support new enums,
data classes, and message channels.
* feat: add callback-based permission handling with timeout
Refactor CallKeep Android permissions flow to use a single pending
callback and listen to real permission results from the Activity.
#113) * refactor: improve ActivityWakelockManager logging and safety * feat: add verbose logging support to Log helper * fix: apply wakelock handling on call update events
…116) * refactor: streamline PhoneConnection lifecycle and audio routing logic - Rework KDoc and simplify state/metadata handling for readability - Extract timeout handling into onTimeoutTriggered() and tidy ConnectionTimeout API/docs - Refactor audio routing for pre/post API 34 with dedicated mappers and helpers - Add endpoint switching helper with OutcomeReceiver wrapper for CallEndpoint changes - Rename/change intent: applyVideoState(), updateModernAudioState(), determineLegacyRoute() - Improve factories param naming/order and centralize safe terminateWithCause() logic * refactor: clean up Log dispatcher and centralize delegate handling - Add global log tag prefix for consistent Android logging - Extract system logging into performSystemLog() - Isolate delegate dispatch logic into dedicated helper methods - Ensure delegate callbacks are always posted on the main thread - Improve readability and separation of concerns in Log utility * fix: log error when requested call endpoint is not found * refactor: reuse main thread handler for log delegate dispatch * refactor: rename availableCallEndpoints and cancel timeout on disconnect * refactor: rename handleIncomingTimeout to handleConnectionTimeout * refactor: improve ConnectionTimeout constants ordering and documentation * refactor: reuse shared executor for audio endpoint changes * chore: update terminateWithCause log message * refactor: rename changeMuteState parameter for clarity * refactor: make logging thread-safe and improve throwable handling * fix: handle null audio device id when selecting call endpoint * chore: remove unused imports and tidy import ordering
Explain why CallMetadata must not implement Parcelable when passed through ConnectionService/Telecom extras: system_server may unmarshal bundles (e.g., ConnectionRequest.toString()), and a custom Parcelable can trigger BadParcelableException/ClassNotFoundException due to missing app ClassLoader. Document the safe approach: manual Bundle field serialization using primitive Android types.
* refactor: clean up Bundle extras handling for CallMetadata - add Bundle.getLongOrNull/getCharOrNull helpers - store audioDevices as ArrayList<Bundle> to avoid Telecom unmarshalling issues - parse created/accepted times safely and ignore default DTMF char * refactor: clarify CallMetadata merge semantics and null-safe bundle parsing - Rename mergeWith() to updateFrom() to better reflect update semantics - Document merge strategy, limitations, and boolean overwrite caveats - Use getCharOrNull() and getLongOrNull() to avoid default primitive fallbacks - Remove verbose toString() override in favor of data class default - Update PhoneConnection to use the new updateFrom() API
* fix: clear FLAG_KEEP_SCREEN_ON on dispose * refactor: centralize proximity + wakelock state syncing in dispatcher * fix: enable proximity sensor only for eligible audio calls
…on accessors (#118) * refactor: expose PhoneConnection metadata accessors and null-safe dispatcher usage * refactor: make CallMetadata boolean flags nullable to support partial updates - Add Bundle safe getters for boolean/string values (getBooleanOrNull, getStringOrNull) - Change CallMetadata boolean fields to nullable and serialize only when present - Update bundle parsing to preserve “missing” vs default values - Adjust call/service logic to use safe fallbacks (?: false) where required - Tighten PhoneConnection encapsulation and update converters to use accessors * test: add unit tests for CallMetadata.updateFrom partial-merge behavior - Enable JVM unit tests in Gradle (src/test/kotlin) and add JUnit + kotlin-test deps - Add CallMetadataUpdateTest covering patch updates, explicit false overwrites, and list merge rules * fix: preserve boolean flags in CallMetadata during partial updates Update the merge strategy to use the Elvis operator (`?:`) for boolean fields (`hasVideo`, `hasSpeaker`, `hasMute`, etc.). Previously, passing `null` in an update object (e.g., during a display name change) would overwrite the existing `true`/`false` state with `null`. This change ensures existing values are preserved if the update value is missing. * refactor: rename CallMetadata.updateFrom to mergeWith
#120) * refactor: consolidate speaker toggle logic across API levels * feat: enforce speaker routing for video calls to avoid startup race - Re-evaluate audio routing when endpoints change and when speaker endpoint changes - Add centralized enforceVideoSpeakerLogic() to keep speaker on for video calls - Skip auto-speaker when Bluetooth is available (prefer BT over speaker) - Apply enforcement on answer, dialing, and when upgrading to video * feat: add speakerOnVideo metadata flag to control auto-speaker * test: expand CallMetadata mergeWith coverage for speakerOnVideo - Simplify tests by merging metadata directly (remove FakePhoneConnection) - Add `speakerOnVideo` merge scenarios (preserve null, preserve explicit false, update null→false, update false→true) - Keep existing coverage for booleans and audioDevices merge behavior * test: add Robolectric tests for video auto-speaker behavior - Add Robolectric/AndroidX test deps and Mockito (core + inline) - Introduce `PhoneConnectionTest` covering speaker enforcement on video updates - Verify `speakerOnVideo` config (null/default, true, false) and non-video cases - Simulate API 34+ call endpoints to avoid startup "endpoints not loaded" early exit * refactor: rename speakerOnVideo bundle key constant for CallMetadata
…121) - Remove redundant `id` property in favor of `callId` to maintain consistency with `CallMetadata`. - Replace `isAnswered()` method and backing field with a idiomatic `hasAnswered` Kotlin property. - Update references in `ConnectionManager` and `PhoneConnectionService` to match the API changes.
* refactor: remove missed call flow and performReceivedCall API * refactor: remove unused missed call notification plumbing
#129) * refactor(android): prevent redundant call endpoint requests and enhance logging Implemented a concurrency guard using a pending request tracker to ensure only one CallEndpoint change is active at a time, preventing race conditions on Android 14+. Added comprehensive debug logging to the video speaker enforcement logic to improve traceability of audio routing decisions. - Added @volatile pendingEndpointRequest to track active OutcomeReceiver operations. - Updated EndpointChangeReceiver to clear the pending request state on both success and failure. - Injected detailed debug logs into enforceVideoSpeakerLogic to track state transitions. * fix: synchronize audio endpoint switching to prevent race conditions * docs: add concurrency notes to performEndpointChange
- Remove manual SensorEventListener for proximity detection. - Delegate screen state management entirely to Android's PROXIMITY_SCREEN_OFF_WAKE_LOCK. - Disable WakeLock reference counting (setReferenceCounted(false)) to prevent lock accumulation during rapid call events. - Persist a single WakeLock instance to prevent orphaned locks from locking the screen indefinitely. - Add idempotency checks (isListening, isWakelockActive) to ProximitySensorManager to avoid redundant state updates.
* fix: prevent speaker auto-reenabling during video calls * chore: enhance logging and traceability in ForegroundService
Corrects an issue where new audio-only calls would incorrectly start on speakerphone if a previous video call was terminated or if the foreground service was stopped forcefully. - Added logic to `onAvailableCallEndpointsChanged` to detect the initial load of audio endpoints. - Forces the audio route to EARPIECE during the first initialization window for audio-only calls, overriding inherited system heuristics. - Added documentation explaining the root cause of sticky audio routing within the Android Telecom Framework.
- Fixed a Fatal NullPointerException caused by `getCurrentCallEndpoint()` returning null during connection setup (despite the @nonnull SDK annotation). - Resolved the "Sticky Speaker" issue where audio-only calls would incorrectly inherit the speakerphone route from previous video calls. - Removed the conditional check for the current endpoint in `onAvailableCallEndpointsChanged` to avoid transient state crashes. - Implemented a mandatory EARPIECE override on the first hardware load for audio-only calls, wrapped in a try-catch for maximum stability. - Added comprehensive technical documentation explaining the root cause of inherited system routing and the defensive strategy implemented.
…udio (#135) Introduce `preventAutoSpeakerEnforcement` logic to PhoneConnection to ensure that calls initiated or answered as audio-only do not automatically switch to speakerphone during a mid-call video upgrade. - Set `preventAutoSpeakerEnforcement` to true if the call starts without video. - Guard `enforceVideoSpeakerLogic` to skip enforcement if the flag is active. - Remove manual speaker disable reset in `applyVideoState` to maintain user preference. This prevents unexpected loud audio if a user is holding the device to their ear when a remote party enables video.
- Introduce `CallkeepIncomingCallMetadata` to the platform interface to represent caller details (callId, handle, displayName, hasVideo). - Update `ForegroundStartServiceHandle` and `CallKeepPushNotificationSyncStatusHandle` typedefs to accept the new metadata parameter. - Add `PCallkeepIncomingCallData` to Pigeon schemas and update `PDelegateBackgroundRegisterFlutterApi` to pass this data between Android and Dart. - Modify Android `CallLifecycleHandler` and `IncomingCallService` to capture and forward call metadata to the Dart isolate during push sync and wakeup events. - Update `README.md` and example project to reflect the new callback signatures. - Regenerate Pigeon communication files for Android and Dart.
…tartCommand (#138) On some devices (notably LGE), the system may restart the service with a null intent or an intent missing its action extra when the app is in the background. This caused an IllegalArgumentException in ServiceAction.from() with "Unknown action: null". Now gracefully returns START_NOT_STICKY when intent or action is null. Co-authored-by: Claude Opus 4.6 <[email protected]>
* 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.
…comingCallService (#222)
…on to internal (#220)
… ActiveCallService (#223)
…n SignalingIsolateService (#224) * fix(android): always call startForeground() before permission check in SignalingIsolateService * fix(android): update signalingSkip to reflect actual limitation
… tests pass (#226) * fix(android): make background signaling and cross-service integration tests pass Three bugs fixed in SignalingIsolateService: 1. Remove stopSelf() after startForeground() in startForegroundService(). Android permits a foreground service to run when POST_NOTIFICATIONS is absent -- the notification is silently suppressed but the service continues. The previous stopSelf() caused ForegroundServiceDidNotStartInTimeException on some devices and prevented the background Flutter engine from registering its IsolateNameServer command port in environments where the permission is absent (e.g. fresh test installs). 2. Replace tearDownService() with sendTearDownConnections() in the non-empty branch of endAllCalls(). tearDownService() dispatches ServiceAction.TearDown whose handleTearDown() only synchronises sensor state and never fires HungUp broadcasts. sendTearDownConnections() dispatches ServiceAction.TearDownConnections whose handleTearDownConnections() calls hungUp() on every PhoneConnection, firing the HungUp broadcasts that ForegroundService receives to dispatch performEndCall() on the Flutter delegate. Test-side changes: 3. example/lib/isolates.dart -- rewire onStartForegroundService to register an IsolateNameServer command port used by integration tests to inject signaling commands (incomingCall / endCall / endCalls) into the background Flutter engine. 4. example/integration_test/callkeep_background_services_test.dart: - Call initializeCallback(onStartForegroundService) before startService() so CALLBACK_DISPATCHER and ON_SYNC_HANDLER are stored in SharedPreferences (bootstrap.dart is not executed in test entry-point builds). - Move delegate.onPerformEndCall listener before call registrations in the signaling endCalls test so early termination events on devices that reject concurrent incoming calls are not missed. - Add concurrent-call support guard (skip) in the cross-service endCall isolation test for devices where Telecom rejects a second incoming call while the first is still RINGING. 5. example/android/app/src/androidTest/java/com/example/example/MainActivityTest.java: - Grant POST_NOTIFICATIONS via pm grant in @before to handle test environments where the permission is not pre-granted. * fix(test): fix lint warnings in callkeep_background_services_test - Replace relative lib import with package import (avoid_relative_lib_imports) - Add explicit SendPort? type annotation instead of dynamic cast * refactor: apply review comments on PR #226 - Clarify MainActivityTest comment: POST_NOTIFICATIONS grant is for general notification reliability - Use signalingServiceCommandPortName constant in test instead of duplicating the string literal - Add default branch to switch in onStartForegroundService - Clarify SignalingIsolateService comment: tearDownService() is safe in the empty-call branch; sendTearDownConnections() required otherwise - Update test comment: onStartForegroundService (not _signalingTestCallback)
…lueConst and AndroidPendingCallHandler (#227) - Delete call_path_key_const.dart and call_path_value_const.dart - these constants have no consumers across the monorepo or in webtrit_phone. - Remove AndroidPendingCallHandler class from android_pending_call_handler.dart - the handler was never instantiated; only PendingCall (co-located) is used. - Update call_const.dart barrel to drop the two deleted exports.
) The safety-net stopTimeoutRunnable was posted in handleRelease() but never removed in onDestroy(). When the service stopped gracefully the runnable still fired ~2 s later, logged a false-alarm warning, and reported a spurious exception to Firebase Crashlytics. Fix: call timeoutHandler.removeCallbacks(stopTimeoutRunnable) at the start of onDestroy() so the timeout is disarmed on any graceful stop path (normal answer/decline teardown, OS-initiated stop, etc.).
…eepCore (#230) * refactor(android): route all connection event receiver calls through CallkeepCore Add registerConnectionEvents/unregisterConnectionEvents to CallkeepCore interface and InProcessCallkeepCore so that ForegroundService, IncomingCallService and SignalingIsolateService no longer call ConnectionServicePerformBroadcaster directly. This makes CallkeepCore the single access point for both commands to :callkeep_core and connection event subscriptions, reducing scattered direct coupling to the broadcaster. * refactor(android): centralize connection event delivery through CallkeepCore listener hub Introduce ConnectionEventListener and add/removeConnectionEventListener to CallkeepCore. InProcessCallkeepCore registers a single lazy global BroadcastReceiver on the first subscriber and unregisters it when the last one leaves (ref-counted). ForegroundService and IncomingCallService now implement ConnectionEventListener instead of registering their own BroadcastReceivers, removing direct coupling to ConnectionServicePerformBroadcaster. Per-call one-off receivers (OngoingCall, OutgoingFailure, TearDownComplete, endCall/endAllCalls confirmations) keep using registerConnectionEvents as they are temporary and call-scoped. * refactor(android): simplify endCall/endAllCalls in SignalingIsolateService Remove per-call BroadcastReceiver, Handler/timeout, and AtomicBoolean waiting patterns from endCall() and endAllCalls(). Both methods now dispatch the teardown command through CallkeepCore and resolve the callback immediately. The signaling layer (WebSocket/SIP) closes via its own lifecycle in webtrit_phone and does not depend on Telecom teardown confirmation, so waiting for HungUp broadcasts before resolving was unnecessary overhead. * docs(android): update architecture docs to reflect ConnectionEventListener routing - callkeep-core.md: add Connection Event Listener API section describing the lazy ref-counted globalReceiver pattern and global vs per-call events - foreground-service.md: replace connectionServicePerformReceiver section with onConnectionEvent() via ConnectionEventListener; update lifecycle - ipc-broadcasting.md: update Receiver Locations table -- single globalReceiver in InProcessCallkeepCore now fans out to listeners - background-services.md: document IncomingCallService as ConnectionEventListener subscriber; add SignalingIsolateService Pigeon host API table with fire-and-forget endCall/endAllCalls behaviour * fix(android): register connection event receivers as not-exported globalReceiver in InProcessCallkeepCore and the registerConnectionEvents default were using RECEIVER_EXPORTED on API 33+, allowing other apps to spoof call lifecycle and media events. Both are now registered as not-exported since all senders use setPackage(packageName).
…231) * refactor(android): replace intra-process broadcasters with StateFlow ActivityLifecycleBroadcaster and SignalingStatusBroadcaster used sendBroadcast for communication that never crossed the process boundary -- all senders and receivers live in the main process JVM. Replace both with StateFlow-backed singletons (ActivityLifecycleState, SignalingStatusState). SignalingIsolateService collects the flows via a CoroutineScope tied to the service lifetime instead of registering BroadcastReceiver instances. Benefits: - Eliminates OEM broadcast suppression risk on locked devices - Removes BroadcastReceiver boilerplate from SignalingIsolateService - Drops the Context parameter from setValue() -- no longer needed - Keeps identical currentValue getter API for IsolateSelector call sites * fix(android): skip initial StateFlow emission in SignalingIsolateService StateFlow always emits its current value on collection start. Without drop(1), synchronizeSignalingIsolate() was called during onCreate() before FlutterEngineHelper was initialized -- an extra call that was absent in the original BroadcastReceiver approach (receivers only fire on new broadcasts, not on registration). Also switch to SupervisorJob so a failure in one collector does not cancel the other. * fix(android): replace StateFlow with SharedFlow in state holders StateFlow deduplicates by value equality -- repeated setValue() calls with the same value emit nothing to collectors. The integration tests rely on updateActivitySignalingStatus triggering synchronizeSignalingIsolate on every poll iteration, which the old sendBroadcast approach did unconditionally. Switch to MutableSharedFlow(replay=0, extraBufferCapacity=1) so every setValue() call delivers an event to active collectors regardless of whether the value changed. currentValue is kept as a @volatile field for direct reads by IsolateSelector. Also removes the drop(1) workaround that was added in the previous commit -- SharedFlow(replay=0) has no initial replay, so the collector is only invoked on explicit setValue() calls, matching BroadcastReceiver registration semantics exactly. * fix: address review comments on PR #231 - Update stale SignalingStatusBroadcaster -> SignalingStatusState reference in CallLifecycleHandlerTest comment - Update background-services.md to reference .updates (SharedFlow) instead of .flow (StateFlow) - Clarify KDoc on both state holders: document that DROP_OLDEST buffer may drop events under backpressure * ci: re-trigger integration tests * fix(android): remove unregisterPhoneAccount from tearDown PR #213 removed it from onDestroy because it breaks phone account availability for subsequent sessions. Restoring the same behavior: tearDown only cleans up active calls without unregistering the account.
* feat(example): add per-suite integration test runner scripts Add _test_lib.sh (shared helpers) and individual run_callkeep_*.sh scripts for running each integration test suite in isolation on a target device. Update run_integration_tests.sh to delegate to these scripts. * fix(example): restore run_integration_tests.sh to main runner format The CI workflow parses run_integration_tests.sh to discover test files. The previous version delegated to tools/ scripts and had no inline test list, causing the grep to find no matches and the CI build to fail immediately. Restore the file to the standard format with an inline TESTS array so the workflow can discover and build APKs for each test file.
…re.telecom (#232) * fix(android): add standalone call mode for devices without android.software.telecom Devices that do not expose the android.software.telecom system feature (tablets, Android Go builds, some OEM configs) previously crashed silently on startup with UnsupportedOperationException from registerPhoneAccount(), then failed all incoming calls because PhoneConnectionService was never started and the Pigeon channel was unreachable. Introduce StandaloneCallService as an independent call manager that takes over when Telecom is unavailable. It runs in the :callkeep_core process and dispatches the same ConnectionEvent broadcasts as PhoneConnectionService, so ForegroundService and the Flutter layer require no changes to support either path. Changes: - TelephonyUtils: add isTelecomSupported(context) feature check - StandaloneCallService: new service managing call state, audio via AudioManager directly, and ConnectionEvent broadcast dispatch without the Telecom framework - PhoneConnectionService: route incoming/answer/decline/hangup/audio/teardown commands to StandaloneCallService when isTelecomSupported() returns false; startOutgoingCall throws UnsupportedOperationException (outgoing calls are not supported without Telecom) - ForegroundService.setUp: skip registerPhoneAccount retry loop when Telecom is unavailable; extract applySetupOptions() so notification channels and storage options are initialised on both paths - AndroidManifest: declare StandaloneCallService in :callkeep_core process, declare android.software.telecom as optional feature * refactor(android): centralize call routing in CallServiceRouter Move all isTelecomSupported() routing out of PhoneConnectionService companion methods and InProcessCallkeepCore. CallServiceRouter is now the single place that decides whether to delegate to PhoneConnectionService (Telecom path) or StandaloneCallService (no-Telecom path). PhoneConnectionService is restored to a pure Telecom backend with no routing logic. InProcessCallkeepCore delegates all CS commands through CallServiceRouter, remaining unaware of which backend is active. * feat(android): add outgoing call support in StandaloneCallService Devices without android.software.telecom can now place outgoing calls. StandaloneCallService handles OutgoingCall and EstablishCall actions: - OutgoingCall: stores metadata, fires OngoingCall broadcast so ForegroundService promotes the call and notifies the Flutter layer - EstablishCall: activates audio, fires AnswerCall broadcast when the remote side connects (mirrors reportConnectedOutgoingCall Telecom path) CallServiceRouter routes startOutgoingCall and startEstablishCall to StandaloneCallService instead of throwing UnsupportedOperationException. * feat(android): implement UpdateCall, SendDtmf, Holding in StandaloneCallService StandaloneCallService now handles the full set of in-call operations without requiring android.software.telecom: - UpdateCall: merges new metadata into the stored call state - SendDtmf: updates stored dtmf char and fires CallMediaEvent.SentDTMF so the Flutter layer receives the confirmation (audio tone is generated by WebRTC) - Holding: sets AudioManager.MODE_NORMAL on hold / MODE_IN_COMMUNICATION on unhold, updates hasHold in stored metadata, fires CallMediaEvent.ConnectionHolding CallServiceRouter routes all three through route() instead of no-op guards. * chore(android): remove unused telephonyUtils field from PhoneConnectionService The instance field was initialized in onCreate() but never read anywhere. startOutgoingCall() creates its own local TelephonyUtils instance. Dead code left from earlier refactoring. * fix(android): defer startForeground() to actual call handling in StandaloneCallService StandaloneCallService was calling startForeground() unconditionally in onCreate(), which caused CannotPostForegroundServiceNotificationException when the service was started for lifecycle-only commands (SyncConnectionState, SyncAudioState, etc.) before ForegroundService.setUp() had registered the notification channels. Two root causes fixed: - startForeground() now called lazily via promoteToForeground(), only from handleIncomingCall() and handleOutgoingCall() -- the two handlers that are triggered by startForegroundService() and genuinely need foreground status. - Notification channels are registered in onCreate() to handle the case where the service starts before setUp() runs in the main process. Also stops the service after lifecycle-only commands when no calls are active or pending, preventing the service from lingering after SyncConnectionState / SyncAudioState with no ongoing call. * fix(android): fix tearDownService race, deferred answer, and audio leak in standalone mode - CallServiceRouter.tearDownService() on standalone path now sends CleanConnections instead of TearDownConnections; prevents a second destructive teardown from racing with the next test's incoming call (HungUp fired prematurely, service stopped early) - StandaloneCallService.handleIncomingCall() now consumes pendingAnswers after dispatching DidPushIncomingCall, implementing the deferred-answer race handling described in the KDoc (ReserveAnswer arriving before IncomingCall is processed) - deactivateAudio() now resets isSpeakerphoneOn and isMicrophoneMute in addition to AudioManager.mode, preventing audio state leaking to other apps after call ends - Fix exception logging in onStartCommand to pass throwable as argument so the full exception is captured * fix(android): restore startForeground() in StandaloneCallService.onCreate() Deferring startForeground() to promoteToForeground() (introduced in f601452) causes ForegroundServiceDidNotStartInTimeException when the service is started via startForegroundService() on a path that does not reach handleIncomingCall() or handleOutgoingCall() within 5 seconds. Restore the immediate startForeground() call in onCreate() now that notification channels are registered there first, eliminating the original CannotPostForegroundServiceNotificationException. promoteToForeground() remains a no-op for subsequent calls because isForeground is already true. * fix(android): prevent unnecessary StandaloneCallService starts and OEM broadcast suppression Skip SyncAudioState/SyncConnectionState when StandaloneCallService is not running to avoid repeated startService/stopSelf cycles that degrade the :callkeep_core process on devices without Telecom support (process becomes bad after many rapid cycles). Add FLAG_RECEIVER_FOREGROUND to sendInternalBroadcast() to bypass MediaTek OEM broadcast suppression observed on Lenovo TB300FU and similar devices. Remove promoteToForeground() from StandaloneCallService.onCreate() - deferred startForeground() is only needed when handling an actual incoming/outgoing call. Calling it before any call exists caused a crash on the login page. * fix(android): restore promoteToForeground() in StandaloneCallService.onCreate() The isRunning guard in CallServiceRouter prevents SyncConnectionState and SyncAudioState from starting StandaloneCallService when no call is active, which eliminates the login-page foreground notification crash. However, removing startForeground() from onCreate() introduced a new failure: on slow-starting MediaTek processes the 5-second startForeground() deadline expires before onStartCommand() is delivered, causing Android to log "Bringing down service while still waiting for start foreground" and mark the process as bad. All subsequent startForegroundService() calls then fail with "process is bad", breaking every test that requires a cross-process broadcast. Restoring promoteToForeground() to onCreate() satisfies the deadline immediately, preventing the bad-process feedback loop. The login-page crash path no longer exists because the isRunning guard stops the service from being started before any call is active. * fix(android): move promoteToForeground() to onStartCommand for call actions only Calling startForeground(FOREGROUND_SERVICE_TYPE_PHONE_CALL) from onCreate() crashes the :callkeep_core process on OEM devices (Lenovo TB300FU, Android 13) when the service is started via startService() rather than startForegroundService(). After enough crashes, ActivityManager marks the process as bad and suppresses all further service starts, breaking incoming and outgoing calls. Root cause: lifecycle-only commands (CleanConnections, TearDownConnections, etc.) are dispatched via communicate() which uses startService(), which does not require startForeground(). Calling startForeground(PHONE_CALL type) in that context causes a process crash on this OEM. Fix: remove promoteToForeground() from onCreate() and call it instead at the top of onStartCommand(), but only for IncomingCall and OutgoingCall - the two actions that arrive via startForegroundService() and impose the 5-second window. onStartCommand() executes on the main thread immediately after onCreate() returns, so the 5-second requirement is still satisfied. * fix(android): route StandaloneCallService events in-process via CallkeepCore On certain OEM devices (e.g. Lenovo TB300FU, Android 13) the system ActivityManager suppresses sendBroadcast calls from the app, so events dispatched by StandaloneCallService never reach ForegroundService. Add CallkeepCore.notifyConnectionEvent() for in-process event delivery. InProcessCallkeepCore maintains inProcessReceivers alongside the existing globalReceiver so both persistent ConnectionEventListeners and per-call dynamic receivers (OngoingCall, TearDownComplete, etc.) receive events without going through AMS. StandaloneCallService now calls CallkeepCore.instance.notifyConnectionEvent() instead of ConnectionServicePerformBroadcaster.handle.dispatch(), keeping it aligned with the single-facade pattern introduced in PR #230. * fix(android): run StandaloneCallService in main process with microphone foreground type Remove android:process=":callkeep_core" so StandaloneCallService shares the same JVM as ForegroundService. This is required for CallkeepCore.instance.notifyConnectionEvent() to reach ForegroundService listeners directly without going through AMS. On Lenovo TB300FU (Android 13) the :callkeep_core process is also permanently marked as bad after repeated startForegroundService() failures, blocking all subsequent starts. Change foregroundServiceType from phoneCall to microphone because the phoneCall type requires the Telecom subsystem, which is absent on devices that use this service. * fix(android): use FOREGROUND_SERVICE_TYPE_MICROPHONE in StandaloneCallService The manifest declares foregroundServiceType="microphone" but promoteToForeground() was still passing FOREGROUND_SERVICE_TYPE_PHONE_CALL to startForeground(), causing an IllegalArgumentException crash on first incoming call. * docs: update wip_context to reflect completed fix/standalone-mode-no-telecom-v2 All 10 test suites now pass on HA1SVX8G (Lenovo TB300FU, no telecom). Document three root causes, final notifyConnectionEvent architecture, and PR #232. * chore(example): remove test runner scripts, moved to separate PR * fix(android): guard FOREGROUND_SERVICE_TYPE_MICROPHONE with API 31 check On API 29-30, FOREGROUND_SERVICE_TYPE_MICROPHONE (0x80) is unknown to the framework. When startForeground() is called with this type, the system throws IllegalArgumentException because the requested type does not match the manifest's type validation on those API levels. The exception propagated uncaught from onStartCommand() since promoteToForeground() is called outside the try-catch block, leaving the 5-second startForeground() window unsatisfied and crashing the process with: Context.startForegroundService() did not then call Service.startForeground() On API < 31, fall back to the 2-arg startForeground() (no type), which bypasses type validation entirely and is safe on all API levels below 31.
* fix(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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 0.5.0
What's new
feat(android): dual-process architecture —
:callkeep_coreOS 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
fix(android): standalone call mode for devices without android.software.telecom(fix(android): standalone call mode for devices without android.software.telecom #232)fix(android): cancel stop timeout in IncomingCallService.onDestroy(fix(android): cancel stop timeout in IncomingCallService.onDestroy #228)fix(android): always call startForeground() before permission check in SignalingIsolateService(fix(android): always call startForeground() before permission check in SignalingIsolateService #224)fix(android): wake screen and show notification on locked screen for incoming calls(fix(android): wake screen and show notification on locked screen for incoming calls #217)fix(android): initialize AssetCacheManager in :callkeep_core process(fix(android): initialize AssetCacheManager in :callkeep_core process #219)fix(android): remove redundant @Synchronized and restrict addConnection to internal(fix(android): remove redundant @Synchronized and restrict addConnection to internal #220)fix(android): remove unsafe cast in ActiveCallNotificationBuilder.setCallsMetaData(fix(android): remove unsafe cast in ActiveCallNotificationBuilder #225)fix(android): handle null intent/action in PhoneConnectionService.onStartCommand(fix(android): handle null intent/action in PhoneConnectionService #138)fix(ci): allow semver dots in release branch names(fix(ci): allow semver dots in release branch names #234)Refactoring
refactor(android): replace intra-process broadcasters with StateFlow(refactor(android): replace intra-process broadcasters with StateFlow #231)refactor(android): centralize connection event delivery through CallkeepCore(refactor(android): centralize connection event delivery through CallkeepCore #230)