Commit f41a46f
release: 0.5.0 (#235)
* fix: resync Android connections on delegate attach (#106)
- 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
* feat: add microphone checking for call
* feat: improve logging and permission annotations for outgoing calls (#109)
* fix: resolve race condition in logging delegate handler (#110)
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 failed outgoing call tracking with metadata, source, and in-memory store (#111)
* feat: add diagnostics API and runtime permission handling (#112)
* 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.
* fix: ensure screen wakelock is applied on call update lifecycle events (#113)
* refactor: improve ActivityWakelockManager logging and safety
* feat: add verbose logging support to Log helper
* fix: apply wakelock handling on call update events
* refactor: move proximity sensor handling to connection lifecycle dispatcher (#114)
* refactor: streamline PhoneConnection lifecycle and logging internals (#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
* docs: warn against @Parcelize for CallMetadata in Telecom bundles
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 (#117)
* 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
* refactor: centralize proximity sensor and wakelock management (#115)
* 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
* refactor: stabilize CallMetadata partial-merge semantics and connection 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
* fix: handle nullable hasVideo and proximityEnabled safely (#119)
* feat: add speakerOnVideo flag and stabilize video call speaker routing (#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
* refactor: unify call ID naming and convert answer state to property (#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.
* fix: always include MICROPHONE foreground service type (#125)
* fix: force disconnect and cleanup connections on task removal (#122)
* refactor: remove missed call flow and performReceivedCall API (#126)
* refactor: remove missed call flow and performReceivedCall API
* refactor: remove unused missed call notification plumbing
* refactor: remove deprecated performSetSpeaker API and ConnectionHasSpeaker event (#127)
* feat: add explicit timeout error for call requests (#128)
* refactor(android): prevent redundant call endpoint requests and enhan… (#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
* fix: resolve black screen hang on call termination (#130)
- 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: speaker behavior and service logging (#132)
* fix: prevent speaker auto-reenabling during video calls
* chore: enhance logging and traceability in ForegroundService
* fix: resolve sticky speaker state on call initialization (#133)
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.
* fix: resolve NPE and sticky speaker state on call initialization (#134)
- 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.
* fix(android): prevent auto-speaker enforcement for calls started as audio (#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.
* feat: pass incoming call metadata to background isolate callbacks (#137)
- 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.
* fix(android): handle null intent/action in PhoneConnectionService.onStartCommand (#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 (#147)
* 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: standardize analysis_options.yaml across all packages (#148)
* 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)
* feat(android): :callkeep_core OS process split — dual-process architecture (#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
depe…1 parent 3117cc4 commit f41a46f
File tree
265 files changed
+30541
-7545
lines changed- .claude
- hooks
- .github/workflows
- lib/src
- tool/scripts
- webtrit_callkeep_android
- android
- lint
- src/main/kotlin/com/webtrit/callkeep/lint
- src
- main
- kotlin/com/webtrit/callkeep
- common
- extensions
- managers
- models
- notifications
- services
- broadcaster
- common
- core
- receivers
- services
- active_call
- connection
- dispatchers
- models
- foreground
- incoming_call
- handlers
- signaling
- receivers
- workers
- test/kotlin/com/webtrit/callkeep
- common
- models
- services
- core
- services
- connection
- incoming_call
- signaling
- docs
- lib/src
- common
- pigeons
- test
- src/common
- webtrit_callkeep_ios
- ios/Classes
- lib/src
- common
- pigeons
- test/src/common
- webtrit_callkeep_linux
- webtrit_callkeep_macos
- webtrit_callkeep_platform_interface
- lib/src
- consts
- delegate
- helpers
- models
- webtrit_callkeep_web
- lib
- webtrit_callkeep_windows
- webtrit_callkeep
- example
- android/app
- src/androidTest/java/com/example/example
- integration_test
- lib
- core
- features
- actions
- bloc
- view
- main
- tests
- bloc
- view
- test_driver
- tools
- lib/src
- android
- services
- utils
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
265 files changed
+30541
-7545
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
16 | | - | |
17 | | - | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
18 | 19 | | |
19 | | - | |
20 | | - | |
21 | 20 | | |
22 | 21 | | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
23 | 25 | | |
24 | 26 | | |
25 | 27 | | |
| |||
40 | 42 | | |
41 | 43 | | |
42 | 44 | | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
43 | 125 | | |
44 | 126 | | |
45 | 127 | | |
| |||
48 | 130 | | |
49 | 131 | | |
50 | 132 | | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
59 | | - | |
| 133 | + | |
60 | 134 | | |
61 | 135 | | |
62 | 136 | | |
63 | | - | |
64 | | - | |
65 | | - | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
66 | 140 | | |
| 141 | + | |
| 142 | + | |
67 | 143 | | |
68 | 144 | | |
69 | | - | |
| 145 | + | |
70 | 146 | | |
71 | | - | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
72 | 156 | | |
73 | | - | |
74 | | - | |
75 | | - | |
76 | | - | |
77 | | - | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
125 | | - | |
| 125 | + | |
| 126 | + | |
126 | 127 | | |
127 | 128 | | |
128 | 129 | | |
| |||
137 | 138 | | |
138 | 139 | | |
139 | 140 | | |
140 | | - | |
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
| |||
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
| 166 | + | |
166 | 167 | | |
167 | 168 | | |
168 | 169 | | |
| |||
176 | 177 | | |
177 | 178 | | |
178 | 179 | | |
179 | | - | |
180 | | - | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
0 commit comments