build: create a new release version 1.3.1#347
Draft
SERDUN wants to merge 53 commits into
Draft
Conversation
…lNotification (#291) * refactor(android): remove dead answered param from releaseIncomingCallNotification * refactor(android): fix KDoc for releaseIncomingCallNotification to mention both paths
… startForeground crash on Android 12 (WT-1511) (#292)
…r (WT-1511) (#293) Adds log markers before and after each startForeground() call site in IncomingCallHandler to disambiguate which call triggers a Bad notification crash on OEM Android 12 devices. - muteIncomingCallNotification(): entry log with callId - showNotification(): before/after startForeground [ringing] - startForegroundCompat(): before/after startForeground [silent] If a crash occurs, the last log before the exception identifies the failing call site: absence of "completed" after "[ringing]" points to the first call; "[silent]" without "completed" confirms the muteIncomingCallNotification path.
* feat(android): native file logging for callkeep services Writes Kotlin logs from all callkeep processes to the shared app_logs.log file so they appear alongside Flutter logs when the user shares a log report. Key changes: - Log.kt: always write to file + system log; replace delegate-based routing (deprecated) with direct AndroidLog calls; unify system log tag to WebtritCallkeep so Studio text search and tag filter work - StorageDelegate: setLogFilePath uses commit() instead of apply() to ensure the path is on disk before callkeep_core reads it on startup - PhoneConnectionService, IncomingCallService, ActiveCallService, StandaloneCallService: call Log.initFromContext(applicationContext) in onCreate() so each service reads the cached log path from SharedPreferences independently of Flutter setUp() timing - Fix stale comment in StandaloneCallService claiming it runs in callkeep_core process (it runs in the main process per the manifest) * chore(example): remove deprecated setLogsDelegate calls * chore(test): suppress deprecated_member_use for setLogsDelegate in tests * fix(review): handle commit() failure and remove dead logs-delegate UI - StorageDelegate: warn via AndroidLog if commit() returns false - Example: remove isLogsDelegateActive state, toggleLogsDelegate method, and Native Logs UI button * fix: use correct Log alias in StorageDelegate * style: rename ok to isCommitted in StorageDelegate * fix(logging): switch to FileOutputStream with sync and add write confirmation log Replace FileWriter with FileOutputStream to eliminate the charset conversion layer, add explicit flush()+fd.sync() to ensure bytes reach the kernel page cache, and log "writeToFile: ok size=N" after each write to diagnose whether writes are reaching the file from the callkeep_core process. * fix(logging): refresh log path per call to fix stale cross-process path PhoneConnectionService runs in :callkeep_core (separate JVM). When the main process updates logFilePath via setUp(), the static Log.logFilePath in callkeep_core is never notified and retains the value from the last initFromContext() call, which may point to an old session path. Call Log.initFromContext() at the start of each onCreateIncomingConnection and onCreateOutgoingConnection to pick up the latest SharedPreferences value before any call logging occurs. Also remove the writeToFile diagnostic log added during debugging. * fix(logging): write native logs to _native.log with rotation via LogFileRotator Kotlin writes to a sibling _native.log file instead of the shared app_logs.log. This avoids Flutter IOSink overwriting native bytes due to stale position tracking. LogFileRotator handles size-based rotation (2 MB) independently of Flutter. * refactor(logging): strip global prefix from native log file, use full tag for logcat writeToFile uses bare class tag (e.g. PhoneConnection) so _native.log lines no longer contain the WebtritCallkeep. prefix. performSystemLog keeps the prefixed tag for logcat. * refactor(logging): remove performSystemLog, logs reach logcat via Flutter pipeline * fix(logging): address PR review issues in native file logging - Add logcat fallback in log() when logFilePath is not yet configured (fix silent drop on early boot) - Use FileChannel.lock() in writeToFile() for cross-process file write safety - Add clearLogFilePath() to Log and StorageDelegate; ForegroundService clears path when setUp receives null logFilePath - LogFileRotator checks return values of delete() and renameTo() and logs warnings on failure - Add @JvmStatic v(tag, message) to companion object to match instance API - Remove redundant Log.initFromContext() calls from onCreateIncomingConnection and onCreateOutgoingConnection (onCreate is sufficient) - Simplify setLogsDelegate() to no-op since Kotlin Log.add() is already a no-op * test: remove dead _LogsDelegateRelay onLog tests, keep smoke tests for no-op API * refactor: remove unused _LogsDelegateRelay class and dead onLog tests * refactor(android): rename logFilePath to nativeLogFilePath and remove implicit path transformation Previously Kotlin silently derived the write path from the provided logFilePath (appending _native.log or .native), creating implicit coupling with the Flutter side which independently computed the same suffix. Now the caller passes the exact target path via nativeLogFilePath and Kotlin writes to it as-is. * fix(android): align nativeLogFilePath null semantics with other setUp options null means "unspecified / leave as-is" for all other Android options. The previous if/else actively cleared the stored path on null, silently disabling logging on repeated setUp() calls without an explicit path. * test(android): add CallkeepAndroidOptions converter and Equatable tests for nativeLogFilePath Covers toPigeon() forwarding, null mapping, and equality checks to catch regressions if the field is dropped from props or the converter mapping changes. * perf(android): sync to disk only for WARN and ERROR log levels fsync on every write blocks threads for 10-50ms on slow eMMC flash. DEBUG/INFO/VERBOSE are high-frequency and flush() to OS page cache is sufficient, data survives process crashes. WARN/ERROR are rare and written to disk immediately to survive hard reboots. * fix(android): pass throwable to AndroidLog.e in writeToFile catch block The two-arg overload discarded the stack trace. The three-arg overload includes the full throwable so root cause is visible in logcat. * fix(android): use lock file to serialize rotation and write across OS processes @synchronized guards threads within one process but not across the main and callkeep_core OS processes. A dedicated .lock file with FileChannel.lock() provides OS-level cross-process exclusion. Rotation and the subsequent FileOutputStream open now happen inside the same lock region, so no process can open an FD to a file that is about to be renamed.
Removes the method-channel-based log forwarding that was deprecated in favour of CallkeepAndroidOptions.nativeLogFilePath. Deleted: - WebtritCallkeepLogs / setLogsDelegate() across all layers - CallkeepLogsDelegate abstract class and CallkeepLogType model - PDelegateLogsFlutterApi pigeon class and PLogTypeEnum pigeon enum - PLogTypeEnumConverter / CallkeepTypeEnumConverter converters - All related tests and example app usage
… version placeholder)
…xternal engine (WT-1538) (#297) * feat(android): add WebtritCallkeep.attachToEngine for host-owned engines Expose a public facade to register callkeep on a Flutter engine created without automatic plugin registration (automaticallyRegisterPlugins=false), e.g. a host-owned foreground-service engine. attachToEngine initializes the application context (ContextHolder) and registers callkeep's background host channels on the given messenger; detachFromEngine unregisters them. Integrators can now wire callkeep onto an external engine without touching internal Pigeon channels, CallkeepCore or ContextHolder. Call-control on such an engine is handled by the internal ExternalEngineCallApi, routing release/end to CallkeepCore. Addresses the persistent/socket cold-start crash where ContextHolder was never initialized on the signaling FGS engine (WT-1538, Bug 1). * feat(android): skip own incoming isolate while hosted on an external engine When callkeep is attached to a host-provided engine via WebtritCallkeep.attachToEngine (e.g. a persistent signaling foreground service), that engine owns the background work. callkeep now tracks attached host engines and, while any is attached, shows the incoming call UI without starting its own background isolate - avoiding a duplicate WebSocket and a redundant onPushNotificationSyncCallback. The decision is engine-attachment state, not call data: CallMetadata is unchanged. detachFromEngine clears the state so callkeep resumes managing its own background work once the host engine is torn down. Part of WT-1538 Phase 2. * docs(android): flag background isolate APIs for transport-neutral rename The two background isolate host APIs carry "PushNotification" in their names, which encodes transport (push vs signaling) into callkeep. callkeep should only initiate a call, not know its transport. Added a TODO to rename them (requires pigeon regen + reference updates): PHostBackgroundPushNotificationIsolateBootstrapApi -> PHostBackgroundIsolateBootstrapApi PHostBackgroundPushNotificationIsolateApi -> PHostBackgroundIsolateApi Part of WT-1538. * docs: add guide for hosting callkeep on a host-owned Flutter engine Add docs/external-flutter-engines.md, a client-facing guide for using WebtritCallkeep.attachToEngine / detachFromEngine to set callkeep up on a Flutter engine the app creates itself (e.g. a foreground service engine built with automaticallyRegisterPlugins=false): when it is needed, what attachToEngine does, the hosted behaviour, lifecycle rules, and a decoupled integration pattern. Link it from the README background modes section. Part of WT-1538. * docs: rewrite app-owned Flutter engine guide as a reference Lead with a concise purpose and use case, then API, behavior, requirements, integration and constraints. Drop the rationale and how-it-works prose in favour of a declarative reference style. * fix(android): initialize AssetCacheManager at all callkeep entry points WebtritCallkeep.attachToEngine, IncomingCallService, ActiveCallService and IncomingCallSmsTriggerReceiver initialized only ContextHolder, not AssetCacheManager, unlike the plugin attach and the connection services. Add AssetCacheManager.init alongside ContextHolder.init at these entry points so every entry point initializes both process-wide singletons. Addresses the asymmetry raised in the PR #297 review. Part of WT-1538. * docs(android): add incoming-call handling decision and outcomes diagrams Add docs/incoming-call-handling.md with two mermaid diagrams: the maybeInitBackgroundHandling decision (host engine / app active / app dead) and the answer/decline/missed terminal outcomes, both transport-agnostic. Link it from the docs index. Part of WT-1538. * docs(android): fix mermaid rendering on github (drop semicolons in diagram text) GitHub mermaid treats ';' inside node/note text as a statement separator and fails to render. Replace it with ',' in the affected diagram labels. Part of WT-1538.
…8) (#298) * fix(WT-1538): centralize pending-callId drain in InProcessCallkeepCore Wrap router.startIncomingCall in try/catch + AtomicBoolean drain-once inside InProcessCallkeepCore.startIncomingCall, the single function that creates the pendingCallIds reservation via tracker.addPending(). Previously the drain was implemented ad hoc per entry point and missing in two of three (BackgroundPushNotificationIsolateBootstrapApi, IncomingCallSmsTriggerReceiver). A synchronous throw inside router.startIncomingCall (e.g. IllegalStateException from ContextHolder, observed in WT-1538) leaked the pending entry permanently, making every subsequent reportNewIncomingCall for the same callId rejected as 'already pending, rejecting concurrent duplicate' until process restart. ownsPending guards against draining a concurrent caller's entry. The 5 s Pigeon-callback timeout in ForegroundService is left in place; it serves a different concern (Telecom-confirmation) and continues to handle the 'neither callback fires' case. Behaviour on success: unchanged. Behaviour on failure: pending drained, original throwable re-raised verbatim (onError callback path also drained before the caller's lambda runs). * refactor(WT-1538): move pendingCallIds duplicate-detection into InProcessCallkeepCore Step 1 of this branch centralized the drain-on-failure but left ForegroundService.reportNewIncomingCall with its own addedPending bail-out, which meant the internal drain's ownsPending guard was no-op on the ForegroundService path (the outer addPending had already taken the entry). The 'centralization' was effectively only active for the bg-isolate and SMS trigger paths. This commit completes the centralization (Path 2b): - The early concurrent-duplicate detection moves into InProcessCallkeepCore.startIncomingCall: if tracker.addPending returns false, dispatch onError(CALL_ID_ALREADY_EXISTS) synchronously and return. - The addedPending variable and its three usages are removed from ForegroundService.reportNewIncomingCall: * the pre-call bail block (now in core), * the drain in timeoutRunnable becomes an unconditional idempotent core.removePending - it only fires when neither onSuccess nor onError fires within 5 s, in which case core owned the entry, * the drain in the onError else branch is removed - core has already drained before invoking the caller lambda. Behaviour note for concurrent main-process duplicate (push-isolate vs foreground for the same callId): * Old: bail returned CALL_ID_ALREADY_EXISTS immediately with no side effects. * New: routes through the existing onError CALL_ID_ALREADY_EXISTS handler in ForegroundService, which calls core.promote and returns CALL_ID_ALREADY_EXISTS to Flutter. The handler's STATE_ACTIVE branch is unreachable in this scenario (checkIncomingDuplicate filters STATE_ACTIVE earlier), so no duplicate performAnswerCall is fired. The else branch calls core.promote(callId, metadata, RINGING) with identical metadata to the winning caller (both derive from the same signaling event for the same callId), making it an idempotent overwrite. The 5 s INCOMING_CALL_CONFIRMATION_TIMEOUT_MS remains untouched - it serves the Pigeon-callback Telecom-confirmation concern, unrelated to pending drain. * docs(WT-1538): document sync-throw contract of startIncomingCall Address PR review feedback on #298: callers of CallkeepCore.startIncomingCall must know that synchronous exceptions from the backend (e.g. uninitialized ContextHolder) propagate to the caller and bypass the onError callback. The behaviour is intentional - re-throwing preserves the original exception's message and stack trace via Pigeon's channel-error envelope, which gives Dart-side diagnostics that a structured PIncomingCallError(INTERNAL) would lose. Doc-only changes (zero behaviour change): - CallkeepCore.kt: KDoc on the startIncomingCall interface method covers both failure modes (logical onError vs synchronous throw) and the caller contract for pre-registered state. - InProcessCallkeepCore.kt: extends the catch-block comment to explain why the throwable is re-raised instead of converted to onError(INTERNAL). - ForegroundService.kt: notes on the call site that the 5 s INCOMING_CALL_CONFIRMATION_TIMEOUT_MS timeoutRunnable doubles as the safety-net for this sync-throw case - it eventually drains pendingCallIds and resolves pendingIncomingCallbacks with CALL_REJECTED_BY_SYSTEM after Dart has already received channel-error via Pigeon. * test(WT-1538): add unit tests for InProcessCallkeepCore.startIncomingCall Covers the five observable branches of the centralized pending-callId lifecycle introduced in this branch: 1. Concurrent duplicate (addPending returns false) - onError fires with CALL_ID_ALREADY_EXISTS, the router is never invoked, the first caller's pending entry is preserved. 2. onSuccess - pending stays reserved (the connection lifecycle owns it). 3. onError callback - pending is drained before the caller's onError lambda is invoked. 4. Synchronous throw - pending is drained, the original throwable is re-raised verbatim, neither onSuccess nor onError fires (the documented contract from the KDoc on CallkeepCore.startIncomingCall). 5. Drain-once guarantee: a misbehaving backend that fires onError twice results in only one tracker.removePending call (verified via a spy). Test infrastructure: - Robolectric (matches MainProcessConnectionTrackerTest convention). - Mockito 5 inline mocking for CallServiceRouter and the drain-once spy. To support testing, InProcessCallkeepCore's primary constructor moves from private() to internal(tracker, routerInit) with default arguments that preserve the production singleton behaviour (instance = InProcessCallkeepCore() still resolves to the same MainProcessConnectionTracker + lazy CallServiceRouter). The visibility expansion is module-local; external modules continue to go through CallkeepCore.instance.
…(WT-1488) (#299) Restructure ios/ to follow Flutter SPM layout: sources moved from Classes/ to webtrit_callkeep_ios/Sources/webtrit_callkeep_ios/, public headers placed under include/webtrit_callkeep_ios/. Add Package.swift targeting iOS 13.0. Podspec updated to reference new paths and bump deployment target from 9.0 to 13.0 to match SPM minimum.
* docs: describe callkeep release and tagging process * docs: use ASCII-only punctuation in release process doc * docs: add tag corrections log and 0.0.2 re-tag record * docs: record 0.3.1 re-tag and note uncorrectable 0.2.0 * ci: enforce tag == umbrella version on tag push
Colour -> Color (match American VGV dictionary) and allow the mermaid keyword autonumber in .github/cspell.json.
On push to main that touches webtrit_callkeep/pubspec.yaml, read the umbrella version and create annotated tag X.Y.Z if missing. Idempotent; tag-version-check stays as the enforcement net. Removes the manual post-merge tagging step.
grep|awk under set -euo pipefail aborts before the explicit error when version is absent; plain awk returns 0 so the empty-value check fires. Follow-up to the auto-tag workflow (#307); mirrors the phone fix.
…#309) * fix: stop surfacing literal "undefined" as the caller number (Android) CallHandle.fromBundle returned the literal string "undefined" when the "number" key was absent, and CallMetadata.number had a second "Undefined" fallback. That value flowed into Telecom (setAddress) and was shown as the caller's phone number, breaking contact lookup. Propagate the absence as null and let callers decide the fallback: - CallHandle.fromBundle now returns CallHandle? (null when number missing). - CallMetadata.number is nullable; name falls back to an empty string. - PhoneConnection presents PRESENTATION_UNKNOWN when there is no number. - startOutgoingCall rejects a missing destination number instead of dialing a placeholder. * test: cover nullable number and name fallback (WT-1141) Add CallMetadata getter tests pinning the WT-1141 fix: number is null (not a literal placeholder) when no handle is set, name falls back to an empty string rather than "Undefined" when both display name and number are absent, name falls back to / prefers the right source otherwise, and a partial merge that omits the handle keeps the existing number. * refactor: make CallMetadata.name nullable and decide unknown-caller at the edge The previous fix replaced the "Undefined" placeholder with an empty string in the name getter, which is just another fabricated value at the wrong layer. Propagate the absence instead: name is now String? (display name or number, else null) and each consumer renders an unknown caller itself: - Telecom (setCallerDisplayName, both sites) uses PRESENTATION_UNKNOWN when null, mirroring the address handling, so the framework shows its own unknown label. - Notifications fall back to a new localized "unknown_caller" string. - ForegroundService passes name through to the non-null performStartCall pigeon contract via orEmpty() (handle is guaranteed present on that path). Update the WT-1141 test accordingly (name is null, not "", when nothing is known). * refactor: assert non-null name on the outgoing promote path instead of orEmpty handle is force-unwrapped on this branch, so CallMetadata.name always resolves to the display name or number; use !! to express that invariant rather than fabricating an empty string for the non-null performStartCall pigeon contract. * refactor: pass nullable name through to the Flutter client unchanged The performStartCall pigeon contract already accepts a nullable displayNameOrContactIdentifier, so forward CallMetadata.name (which may be null) directly instead of asserting non-null or substituting a placeholder. The Flutter client decides how to render an unknown caller. * fix: handle missing call handle without crashing and unify the error type A null number now yields a null handle, so the existing handle!! force-unwraps could throw on a malformed/handle-less broadcast. Add InvalidCallMetadataException and: - OngoingCall promote path: fail the request through the normal channel (saveFailedOutgoingCall + finish(Result.failure)) instead of crashing or leaving a ghost call that Telecom shows but Flutter never learns of. - didPushIncomingCall: skip the notification with a warning rather than crash. - startOutgoingCall: throw InvalidCallMetadataException instead of a bare IllegalArgumentException for the same missing-number condition. Also use isNotBlank() in CallMetadata.name so whitespace-only display names fall back to the number. * test: cover bundle-level absence and whitespace name fallback (WT-1141) Add a Robolectric CallHandleTest for CallHandle.fromBundle (null bundle, missing number key, present number, toBundle/fromBundle round-trip) and for CallMetadata.fromBundleOrNull leaving handle/number null when the number key is missing. Add a whitespace-only display name case to the name fallback tests.
…lutter (#310) * feat(android): expose call delivery mode (Telecom vs standalone) to Flutter Devices without android.software.telecom fall back from the Telecom ConnectionService path to a limited StandaloneCallService. That state was detected natively (TelephonyUtils.isTelecomSupported / CallServiceRouter) but never surfaced to Flutter, so the app could not tell the user their device runs in the restricted standalone mode. Add a PHostPermissionsApi.getCallDeliveryMode() getter returning a new CallkeepAndroidCallDeliveryMode enum (telecom / standalone / unknown), wired through every layer: pigeon definition, Kotlin host implementation (reuses the same isTelecomSupported gate the router uses), the android platform plugin and converter, the platform interface model, and the public WebtritCallkeepPermissions API. Non-Android platforms return unknown. This is distinct from the existing battery-optimization warning; it is the prerequisite for warning the user about limited call delivery on devices without Telecom. * fix(web): return unknown call delivery mode on web and fix dartdoc link Address review feedback: - webtrit_callkeep_web now overrides getCallDeliveryMode() to return CallkeepAndroidCallDeliveryMode.unknown, mirroring the existing getBatteryMode default so the web platform impl does not fall through to the throwing platform-interface default. - Replace the broken dartdoc reference [ConnectionService] with a code-formatted android.telecom.ConnectionService to avoid a broken doc link.
… (WT-1116) (#311) * fix(android): replace !! with local val captures in ForegroundService teardown paths (WT-1116) * fix(android): replace !! with local val captures in WebtritCallkeepPlugin lifecycle and bind paths (WT-1116) * fix(android): handle null metadata in IncomingCallService notification action handlers (WT-1116) * fix(android): replace !! with local val captures in PhoneConnection and PermissionsApi timeout runnables (WT-1116) * fix(android): eliminate remaining unsafe !! in AudioDevice, IncomingCallNotificationBuilder and ForegroundService (WT-1116) * fix(android): address Copilot review -- sync catch block in PermissionsApi, map unknown AudioDeviceType to UNKNOWN (WT-1116)
…313) * chore(android): add unit test CI workflow and document JDK 17 requirement Gradle 8.14.3 bundles ASM 9.7.1 which cannot process class file version 70 (Java 26). When the Gradle daemon runs on JDK 26, Groovy emits Java 26 bytecode for compiled build scripts, and Gradle's own instrumenter immediately rejects them. Upgrading to a Gradle version with ASM 9.9+ (which supports Java 26) requires AGP 9.0+, a major version bump that is out of scope here. Fix: run the Gradle daemon on JDK 17. The CI workflow installs JDK 17 via actions/setup-java, which sets JAVA_HOME before the Gradle wrapper starts. Local developers must do the same (see the new comment in gradle.properties). Also bumps Robolectric 4.16 -> 4.16.1 (patch release, no API changes). * chore(android): upgrade Gradle 8.14.3 -> 9.5.1 and fix Robolectric JDK 26 issue Gradle 8.14.3 bundled ASM 9.7.1 (max Java 25); Groovy running on JDK 26 emits class file version 70, which Gradle's own instrumenter then rejects at build-script compile time. Gradle 9.5.1 ships ASM 9.9 which supports Java 26, fixing build-script compilation under JDK 26. Robolectric's own bundled ASM still does not support Java 26 at test runtime. Fix: pin the test JVM to JDK 17 via Gradle Java Toolchain (tasks.withType(Test)). Foojay resolver 1.0.0 (first release compatible with Gradle 9.x) auto-provisions Temurin 17 when it is absent. Result: `./gradlew testDebugUnitTest` now passes 198/198 on JDK 26 host. * chore(android): remove obsolete JDK 17 comment from gradle.properties
…l (WT-1132) (#312) * fix(android): serialize OutgoingFailureType by name instead of ordinal (WT-1132) Ordinal-based serialization breaks when enum values are reordered or inserted. Intents carrying FailureMetadata bundles can survive process death, so a stale ordinal from a prior version causes IndexOutOfBoundsException at deserialization. Switching to name-based serialization makes the encoding stable across enum changes; unknown names fall back to UNENTITLED. * test(android): add FailureMetadata bundle serialization tests (WT-1132) Covers round-trip for both OutgoingFailureType values, unknown-string fallback to UNENTITLED, and missing-key fallback. * build(android): track Gradle wrapper files for standalone CI test runs gradlew, gradlew.bat, and gradle-wrapper.jar were excluded by root .gitignore, so the android-unit-tests workflow had no ./gradlew to invoke. Remove those three entries from .gitignore and commit the files. * ci(android): install Flutter SDK before running unit tests Without flutter.jar the Kotlin compiler cannot resolve io.flutter.* references in main sources. Install Flutter via subosito/flutter-action and write local.properties so the build.gradle guard picks up the SDK path.
…ontract coverage (#314) * refactor(tests): extract shared test helpers from integration test files _RecordingDelegate, _waitFor, _waitForConnection, _waitForConnectionGone, _options, _handle1/2, and _nextId were copy-pasted across all 10 integration test files. Extract them into integration_test/helpers/callkeep_test_helpers.dart. The shared RecordingDelegate is the union of all per-file variants: includes audioDevicesUpdateEvents (from stress test), activateAudioSessionCount / deactivateAudioSessionCount / pushIncomingEvents (from delegate_edge_cases), and performAnswerCallOverride / performEndCallOverride (from client_scenarios). * fix(tests): fix flaky setDelegate(null) mid-call test in full-suite run After 80+ tests the FGS processes requests more slowly; the previous 10s waitFor timeout was too tight. Two changes: - add 500ms settle after reportNewIncomingCall so the FGS broadcast registers the call before answerCall races it - increase waitFor timeout from 10s to 30s for this test only, since the goal is crash-safety, not strict timing * test(android): add integration tests for getCallDeliveryMode Covers: valid enum result, telecom/standalone on real device, stability across consecutive calls, and callability without setUp. Non-Android group verifies the `unknown` fallback path. * test(android): add releaseCall/handoffCall contract tests for missing service Both methods require IncomingCallService (started only by FCM) to be running. Without it the Pigeon channel has no handler and the Dart side receives a null reply, which maps to PlatformException("channel-error"). Tests document this contract so callers know to guard against PlatformException when the service is unavailable (app in foreground, no push in flight). * fix(tests): use waitForConnection polling instead of fixed delay for flaky test After 80+ tests the FGS can take much longer than 500 ms to deliver the broadcast. Poll until the PhoneConnection is visible (FGS broadcast processed) before calling answerCall -- then the answer arrives quickly and the 15 s waitFor is plenty even under high load. * fix(tests): fix two more timing failures in full-suite run setDelegate(null) mid-call: switch from waitForConnection polling to didPushIncomingCall callback (set up before reportNewIncomingCall). waitForConnection returned null after 30s under load since the PhoneConnection never became visible via CallkeepConnections API while the FGS was backlogged. The didPushIncomingCall callback is the authoritative signal that the connection is established. transfer-back test: add waitForConnectionGone after the first call ends before re-registering the same callId. After 100+ tests Telecom may be slow to remove the disconnected connection slot; re-reporting too early returns callRejectedBySystem. * fix(tests): increase answerCall timeout to 60s in flaky setDelegate test After 80+ tests the Telecom answer round-trip can take well over 15s due to accumulated FGS/ConnectionService overhead. The test goal is crash-safety (setDelegate null while call is active), not timing, so a generous timeout is correct here. * fix(tests): switch flaky setDelegate(null) test to outgoing-call path After 80+ tests the FGS incoming-call broadcast pipeline is completely backlogged -- didPushIncomingCall and answerCall never complete. The test goal is crash-safety of setDelegate(null) while a call is active, not specific to call direction. Switch to startCall + reportConnected which goes through synchronous CS IPC and remains reliable under load.
…est (#315) * test(android): add 8s timeout to concurrent spam test to fail fast Without the timeout Future.wait hangs indefinitely (known race in ForegroundService.reportNewIncomingCall: pendingIncomingCallbacks[callId] is cleared by a concurrent duplicate onError handler before DidPushIncomingCall arrives -- cb[0] never resolved). Test now fails within 8s with a descriptive message pointing to the root cause. The strict expect(successes, 1) expectation is preserved for when the Kotlin fix lands. Also includes gradle.properties flags auto-added by Flutter migrator (android.builtInKotlin=false, android.newDsl=false). * test(android): remove internal path reference from fail message * test(android): document concurrent spam test invariant
…same-callId spam (#316) * fix(android): guard pendingIncomingCallbacks slot against concurrent same-callId spam (WT-1538) Use putIfAbsent instead of a plain map assignment so only the first concurrent reportNewIncomingCall call for a given callId registers its Pigeon callback and posts the safety timeout. Subsequent duplicates (ownsPendingSlot=false) skip registration and, in their onError handler, leave both maps untouched so the first callback is preserved until DidPushIncomingCall arrives and resolves it via resolvePendingIncomingCallback. Previously, call[1]'s onError removed the map entry it had overwritten, leaving the map empty before DidPushIncomingCall could fire. The first Future never resolved, causing Future.wait to hang indefinitely. * fix(android): move addNewIncomingCall to callkeep_core to fix callId reuse race (WT-1538) In the dual-process architecture, destroy() is called from :callkeep_core and addNewIncomingCall was called from the main process. These use different Telecom binder connections, so ordering is not guaranteed. After 15+ test cycles Telecom's binder queue becomes congested enough that addNewIncomingCall is processed before the previous destroy(), triggering onCreateIncomingConnectionFailed for the reused callId and surfacing as CALL_REJECTED_BY_SYSTEM in the re-reporting test. Add ServiceAction.AddNewIncomingCall: the main process sends a single IPC to :callkeep_core, which calls addPendingForIncomingCall and then addNewIncomingCall from within the same process. Both addNewIncomingCall and any preceding destroy() now share the same Telecom binder connection (same process), so Telecom processes them in FIFO order. This eliminates the cross-process binder race entirely. 27/27 integration tests pass, including "after decline, re-reporting same ID succeeds" which was intermittently failing after 15+ prior test runs. * fix(android): call onError on startService failure instead of onSuccess (WT-1538) startIncomingCall was calling onSuccess() unconditionally even when startService(AddNewIncomingCall) threw, which incorrectly signalled success to the caller and bypassed the onError path that ForegroundService relies on to drain pendingIncomingCallbacks. Move onSuccess() into runCatching.onSuccess and replace the HungUp broadcast with onError(UNKNOWN) so the Pigeon callback is resolved through the standard ForegroundService.onError else-branch. * fix(android): guard addPendingForIncomingCall against force-terminated callId (WT-1538) addPendingForIncomingCall unconditionally removed the callId from forcedTerminatedCallIds before adding to pendingCallIds, silently defeating the tearDown guard if a stale AddNewIncomingCall intent arrived after TearDownConnections had already processed. In that rare ordering (cross-session delayed intent or non-FIFO delivery), cleanConnections would have already snapshotted the session state, and the subsequent addNewIncomingCall would create a PhoneConnection for a closed session whose HungUp/DidPushIncomingCall broadcasts land in an already-cleared main-process tracker. addPendingForIncomingCall now returns false when the callId is in forcedTerminatedCallIds (i.e. tearDown has already completed for this slot) and does not modify pendingCallIds. handleAddNewIncomingCall checks the return value: on false it dispatches HungUp so the main process resolves the pending Pigeon callback and skips the addNewIncomingCall Telecom call entirely. The defense-in-depth fallback in onCreateIncomingConnection is unaffected: it is already guarded by !isForcedTerminated, so addPendingForIncomingCall always returns true there. handleNotifyPending also benefits silently. * fix: cleanup after code review - extract helper, nullable timeout, full metadata - Extract dispatchHungUpAndRemovePending() helper in PhoneConnectionService, eliminating three copies of the removePending + dispatch(HungUp) pattern (handleAddNewIncomingCall, onCreateIncomingConnectionFailed) and fixing the CallMetadata stripping that previously used CallMetadata(callId=callId) instead of the full bundle. - Change timeoutRunnable in ForegroundService from a non-nullable val (initialized to a no-op Runnable when ownsPendingSlot is false) to Runnable?, so the timer is only allocated and registered when the slot is actually owned. The removeCallbacks() call is updated to the null-safe form. * test(android): run timing-sensitive suites before call_scenarios (WT-1538) delegate_edge_cases, foreground_service, and stress fail when run after 80+ setUp/tearDown cycles: Telecom rejects new outgoing calls with onCreateOutgoingConnectionFailed and the FGS broadcast pipeline backs up, causing DidPushIncomingCall timeouts. Running them in positions 13-62 (before the 50-test call_scenarios suite) keeps them below the degradation threshold.
…317) * chore(ci): run Firebase integration tests on merge and release only Previously the workflow triggered on every pull_request to develop, running expensive Firebase Test Lab jobs during code review. Now it triggers on push to develop (i.e. after merge) and on version tags (created automatically by auto-tag-version.yaml on main). workflow_dispatch is preserved for manual runs. * chore(ci): also trigger Firebase tests on push to release/* branches * chore(ci): remove redundant tag trigger from Firebase integration tests Tags are created by auto-tag-version.yaml after merge to main -- running tests at that point is too late to block a bad release. Tests on release/* branches already cover the release candidate before main merge.
* chore: bump pub dependencies to latest compatible versions - platform_interface: lints ^5 -> ^6 - example: go_router ^16 -> ^17, logging_appenders ^1 -> ^2 * chore: upgrade pigeon to 27.1.0 and regenerate bindings - android, ios: pigeon ^26.0.3 -> ^27.0.0 (resolved 27.1.0) - regenerate callkeep.pigeon.dart, test pigeon dart, Generated.kt, Generated.h/.m - restore PLogTypeEnum + PDelegateLogsFlutterApi in Generated.kt (legacy symbols removed from pigeon definition but still needed by Log.kt / WebtritCallkeepPlugin.kt for source compatibility) - fix ios pigeon config: objcHeaderOut/objcSourceOut now point to the SPM path instead of the stale ios/Classes/ which was not used by any build
#319) * fix: parse service intents into typed commands to avoid metadata crash Both PhoneConnectionService and StandaloneCallService eagerly parsed call metadata via CallMetadata.fromBundle at the top of onStartCommand, BEFORE the surrounding try/catch. Binder IPC can deliver a non-null but empty Bundle for the no-extras lifecycle commands (TearDownConnections, CleanConnections, SyncAudioState, SyncConnectionState), so the missing-callId IllegalArgumentException propagated uncaught out of onStartCommand and crashed the :callkeep_core process (confirmed in Play Vitals). Introduce PhoneServiceCommand / StandaloneServiceCommand sealed types with a from(intent) factory that parses the action and metadata once. Lifecycle commands carry no metadata and never touch the extras; Reserve/Pending carry a non-null callId; AddIncoming/Call carry non-null metadata. onStartCommand switches over the typed command, so metadata parsing only runs for actions that need it and any parse failure is non-fatal. This supersedes PR #301, which only switched PhoneConnectionService to fromBundleOrNull and left StandaloneCallService with the same latent crash. WT-1142 * fix: promote standalone call-setup to foreground before command parse When StandaloneServiceCommand.from() returned null (a call-setup intent with empty/truncated Binder extras), onStartCommand bailed out before promoteToForeground(). IncomingCall/OutgoingCall are started via startForegroundService, so skipping startForeground() crashes the process with ForegroundServiceDidNotStartInTimeException ~5s later. Decide the foreground promote from the raw StandaloneServiceAction (new isCallSetup predicate on the enum) BEFORE the command-parse bail-out, so the 5s window is satisfied even when metadata fails to parse. On the bail-out path, call the extracted stopIfIdle() so a promoted-but-unparsed call-setup intent does not linger as a zombie foreground service. Drop the now-unused isCallSetup property from StandaloneServiceCommand. WT-1142 * fix: reject connection on missing callId instead of crashing onCreateOutgoingConnection and onCreateIncomingConnection parsed metadata with CallMetadata.fromBundle, which throws IllegalArgumentException when callId is absent. request.extras comes from our own metadata.toBundle(), so a missing callId is a "should never happen" invariant violation (Binder truncation, process death/recovery, stale framework callback) -- but the uncaught throw would crash the whole :callkeep_core process, the same crash class fixed in onStartCommand. Use fromBundleOrNull and, on null, log an error and return a failed Connection (DisconnectCause.ERROR) so the framework rejects this one connection while the process stays alive. WT-1142 * fix: log distinct reason when a service command is ignored The command factory returns null both for an unrecognised action and for a known action missing its required callId/metadata, and onStartCommand logged a single generic message for both. Branch the log on whether the action resolves to a known enum value, restoring the diagnostic specificity (known-action missing field vs unknown action) lost in the ServiceCommand refactor. WT-1142
…te (#320) The workflows pinned Flutter 3.32.0 (Dart 3.8.0), but webtrit_callkeep_android and webtrit_callkeep_ios now depend on pigeon ^27.0.0 which requires Dart >=3.9.0, so `flutter pub get` failed and the build check went red on PRs. Bump the pinned Flutter version to 3.44.1 (Dart 3.12.1) across all package workflows and the Firebase integration workflow. The bump surfaced a second failure: the reusable format step ran dart format over the generated *.pigeon.dart files (the local lefthook hook excludes them, the CI step did not), and the newer formatter rewraps them. dart format has no exclude flag and ignores analysis_options formatter.exclude, so build the file list explicitly in flutter_package.yml, skipping generated sources and non-existent dirs.
The incoming-call notification showed the same generic "Incoming call" title/text for audio and video calls, so users had no warning that answering would turn on the camera. The hasVideo flag already reaches CallMetadata but was never consulted when building the notification. Branch the title, description and small icon on meta.hasVideo: - add incoming_video_call_title / incoming_video_call_description strings - add ic_notification_video small-icon vector - apply the branch in IncomingCallNotificationBuilder (ringing + silent) and StandaloneIncomingCallNotificationBuilder
…s (WT-1073) (#322) * fix(android): keep ringtone for a still-ringing call when another ends (WT-1073) In standalone mode a single shared Ringtone instance serves all calls, and handleDeclineCall/handleHungUpCall stopped it unconditionally. When a first incoming call timed out while a second was still ringing, the shared ringtone was stopped and the second call went silent. Gate the stop on a new hasOtherRingingCall() predicate (a callId still in callMetadataMap and not yet answered): the ringtone/call-waiting tone is now stopped only when the last ringing call ends. Mirrors the start-path guard added in WT-1388. Adds unit coverage for the predicate. * fix(android): track ringing-incoming set and cover answer/clean paths (WT-1073) Address review of the ringtone-stop guard: - Define 'still ringing' via an explicit ringingIncomingCallIds set instead of the full callMetadataMap, so an outgoing/dialing call (in the map, not yet answered, never plays the ringtone) no longer wrongly keeps the ringtone alive when an incoming call is declined. - handleCleanConnections now also stops the ringtone (it only stopped the call-waiting tone), matching handleTearDownConnections; otherwise a ringtone kept alive by the guard could keep playing after a clean. - After a call is answered, promote a second still-ringing call to the call-waiting tone instead of leaving it silent (answer path, not only the decline/timeout path). - Extend tests for the outgoing-call and answer-promotion cases. * docs(android): clarify ringingIncomingCallIds lifecycle (WT-1073) The set is not pruned on answer, so it may contain answered calls; document that 'still ringing' is membership here AND absence from answeredCallIds.
…cess-bad (#323) * fix(android): report incoming calls via TelecomManager to survive "process is bad" On an incoming FCM push, startIncomingCall reported the call by sending an in-process AddNewIncomingCall startService to the dedicated :callkeep_core process, whose handler then called TelecomManager.addNewIncomingCall. On aggressive OEM power managers (e.g. Xiaomi/MIUI) the :callkeep_core process is killed and flagged "process is bad", so that startService throws SecurityException and the incoming call is silently dropped (no Telecom connection, no UI, no ringtone). Report the incoming call directly via TelephonyUtils.addNewIncomingCall (which wraps TelecomManager.addNewIncomingCall) from the reporting process - one consistent path. The Telecom system server then binds the ConnectionService itself with BIND_AUTO_CREATE, which launches/revives :callkeep_core even when an app-side startService cannot. onCreateIncomingConnection registers the pending slot itself, and on a cold push the self-managed PhoneAccount is re-registered and addNewIncomingCall retried once if the first call throws. - startIncomingCall: drop the in-process AddNewIncomingCall startService hop; call addNewIncomingCall directly, with a re-register-and-retry-once fallback. - Remove the now-dead AddNewIncomingCall plumbing: ServiceAction value, PhoneServiceCommand.AddIncoming, handleAddNewIncomingCall, the onStartCommand branch, the dispatcher arm, and its unit test. - onCreateIncomingConnection / onCreateIncomingConnectionFailed: logic unchanged, comments updated to the direct-report model. - Manifest doc comment updated. Outgoing already used TelecomManager.placeCall directly, so it is unaffected. Verified: webtrit_callkeep_android :testDebugUnitTest green; example integration suite on a physical Pixel 9. Known limitation: same-callId reuse (blind transfer-back) is not deterministic under heavy back-to-back load because the new addNewIncomingCall (reporting process) and the prior Connection.destroy() (:callkeep_core) no longer share one Telecom binder; rare in production. Tracked as a follow-up. * test(android): isolate load-sensitive transfer-back from the aggregate suite The transfer-back test (same-callId reuse) was deterministic only because of the in-process startService FIFO that this PR removes for the OEM "process is bad" fix: with the direct addNewIncomingCall path, the new registration and the prior call's Connection.destroy() run on different process binders, so there is no destroy-before-readd ordering. Once the aggregate run has backed Telecom up (~140 tests), the re-report transiently fails with callRejectedBySystem. That backlog is a suite artifact, not a production condition. - Skip the transfer-back test in all_tests.dart (skipTransferBackUnderLoad flag); it runs reliably standalone (flutter test callkeep_background_services_test.dart), which is production-representative. retry: 2 belt-and-braces for one-offs. - Fix waitForConnectionGone: a disconnected connection lingers in the native map until tearDown, so getConnection() never returns null - treat stateDisconnected as gone, otherwise the helper just sleeps the full timeout. Deterministic transfer-back under load (cross-process teardown serialization) is a separate follow-up.
…ming (#325) callkeep is a generic ConnectionService/CallKit plugin and has no concept of "signaling" — that is the host app's layer. The guard that suppresses a duplicate push-path didPushIncomingCall actually means "this incoming call was reported by the host via reportNewIncomingCall" (as opposed to discovered via the push/Telecom path). Rename to reflect what callkeep itself knows: - markSignalingRegistered -> markReportedIncoming - consumeSignalingRegistered -> consumeReportedIncoming - signalingRegisteredCallIds -> reportedIncomingCallIds Also update the suppression log line and the related doc comments. Pure rename, no behavior change; unit tests unchanged and green.
markReportedIncoming / consumeReportedIncoming de-duplicate the incoming-call notification to Flutter. markReportedIncoming flags a call the host reported via reportNewIncomingCall (the app/signaling path); consumeReportedIncoming suppresses the duplicate push-path DidPushIncomingCall that still arrives afterwards via the :callkeep_core IPC round-trip. Consume-on-read, so the guard fires at most once and a later explicit re-emit to a newly attached delegate is unaffected.
…ot on show-UI (#327) The Telecom path emitted DidPushIncomingCall from PhoneConnection.onShowIncomingCallUi — a system UI callback the framework schedules separately from (and after) onCreateIncomingConnection. That coupled the control-plane notification (notify Flutter, resolve the pending reportNewIncomingCall callback, promote, suppression) to UI-show timing, and diverged from the Standalone (no-Telecom) path, which already emits it deterministically at handleIncomingCall — its comment even claims parity with onCreateIncomingConnection. Move the dispatch into PhoneConnectionService.onCreateIncomingConnection so it fires deterministically when the connection is created, alongside the ConnectionCreated lifecycle event. Emit only in the not-yet-answered branch: a call with a consumed deferred answer is answered immediately (no incoming UI) and is surfaced to Flutter via the answer flow, matching the previous behaviour where onShowIncomingCallUi did not fire for an immediately-answered call. onShowIncomingCallUi keeps presentation only (notification + ringtone/call-waiting tone). Telecom and Standalone paths now emit the event at the same lifecycle point. No public API change. Behavioural change limited to emit timing/site; needs device verification (incoming ring, call-waiting, immediate-answer, push->foreground handoff).
… connection state (#328) * refactor(android): mirror real connection state into shadow state (phase 1) Begin making PhoneConnection.onStateChanged the source of truth for the main-process shadow call-state instead of inferring a fixed state per lifecycle event (DidPushIncomingCall->RINGING, AnswerCall->ACTIVE, ConnectionHolding->HOLDING, ...). - Add a local CallConnectionState enum (models) with fromTelecomState(); the model stays free of the Pigeon-generated PCallkeepConnectionState — conversion to it happens only at the core/tracker boundary (MainProcessConnectionTracker). - Carry it on CallMetadata.connectionState (transient) and add a ConnectionStateChanged lifecycle event. - PhoneConnection.onStateChanged emits ConnectionStateChanged for live states (RINGING/DIALING/ACTIVE/HOLDING); terminal DISCONNECTED stays on the cause-carrying HungUp/DeclineCall path (preserves DisconnectCause). - ForegroundService mirrors it via MainProcessConnectionTracker.updateState — idempotent, only updates an already-tracked call, never creates an entry or touches lifecycle guards. Additive and behavior-preserving: the existing hardcoded state stamping is kept, so the mirror corroborates it. Dropping the per-event inference (and Standalone parity) are follow-up phases. Unit tests added for updateState; PhoneConnectionTerminateTest now filters the orthogonal ConnectionStateChanged events. * refactor(android): drop per-event state inference; standalone parity (phase 2+3) Make the shadow connection state mirror-only, removing the hardcoded event->state stamping now that PhoneConnection.onStateChanged drives it. - markAnswered: lifecycle guard only (answeredCallIds); no longer stamps STATE_ACTIVE. - markHeld: removed entirely (interface + impl + the ForegroundService call) — HOLDING/ACTIVE is now mirrored. The Holding handler still relays performSetHeld to Flutter. - updateState writes connectionStates unconditionally (like the removed stampers did), so the state survives an addPending() reset and the cold-start "already answered" detection in reportNewIncomingCall (getState == STATE_ACTIVE) keeps working — fed by the mirror instead of markAnswered. - promote keeps its initial registration snapshot (RINGING/DIALING/ACTIVE); the mirror owns all subsequent transitions. Phase 3 (Standalone parity): StandaloneCallService has no android.telecom.Connection / onStateChanged, so it now emits ConnectionStateChanged explicitly at its transitions (answer/establish -> ACTIVE, hold -> HOLDING/ACTIVE, sync re-emit -> ACTIVE), keeping the no-Telecom backend in sync after the stampers were removed. Verified: gradle testDebugUnitTest green (tests updated for the new behavior); example integration suite on device (Xiaomi 25028RN03Y / Android 15) 155 passed / 6 skipped / 0 failed, incl. state_machine (answer/hold/unhold), background_services (cold-start/push), connections, lifecycle. * refactor(android): address PR review on the state mirror - markAnswered KDoc: drop the stale "advance to STATE_ACTIVE"; it is a guard only. - updateState contract (ConnectionTracker / CallkeepCore): document that it writes state UNCONDITIONALLY (not gated on connections membership; callable before promote; survives addPending), matching the actual behavior and the cold-start adoption requirement. - MainProcessConnectionTracker.updateState: guard against DISCONNECTED so a future ConnectionStateChanged(DISCONNECTED) call site cannot override the markTerminated path. - StandaloneCallService: wrap the long ConnectionStateChanged copy(...) onto a local val for readability. * docs(android): sync docs with the connection-state mirror Reflect the state-mirror refactor in the android doc set: add the new ConnectionStateChanged event to the IPC catalogue and flows, replace the markHeld removal and markAnswered state-stamp with the updateState mirror, document onStateChanged and the CallConnectionState model. Also fix two pre-existing README staleness items (dart format line-length 80 -> 120, add the callkeep_delivery_mode test and all_tests aggregator).
…es (#329) The command and its plumbing were misnamed 'sync': it is not a two-way reconciliation but a one-way pull where the main process asks :callkeep_core to replay (re-fire) its current connection lifecycle to a freshly attached delegate. Rename to convey that intent across the call chain: - CallkeepCore/InProcessCallkeepCore/CallServiceRouter sendSyncConnectionState -> replayConnectionStates - PhoneConnectionService.sendSyncConnectionState -> replayConnectionStates; handleSyncConnectionState -> handleReplayConnectionStates (same in Standalone) - ServiceAction/StandaloneServiceAction SyncConnectionState -> ReplayConnectionStates; PhoneServiceCommand/StandaloneServiceCommand SyncConnection -> ReplayConnections - update KDoc/comments/logs and the docs/ references; clarify the one-way replay semantics. SyncAudioState is a separate feature and is untouched. No behaviour change. Gradle unit tests green.
…ice create (#330) replayConnectionStates() ran in ForegroundService.onCreate(), which fires BEFORE the Flutter delegate is attached (it is set on service bind, and the Dart receiver is registered in setDelegate). Its re-fired lifecycle events therefore raced the delegate attach: delegate-facing delivery (e.g. a still-ringing incoming call re-delivered to a freshly attached engine) could be dropped, and onCreate() does not run again on a warm engine re-attach. Move the replay to onDelegateSet() - the deterministic 'delegate ready' signal (Dart setDelegate -> onDelegateSet), which fires on every attach. It runs ungated (before the local-tracker isEmpty check) because in a push->foreground handoff the main process has no record of the call yet; the replay from :callkeep_core is what surfaces it. The re-fired AnswerCall still repopulates the shadow tracker (markAnswered) for the cold-start CALL_ID_ALREADY_EXISTS dedup - now driven from the delegate-ready point. Update foreground-service.md.
…in onDelegateSet (#331) The early-return on core.getAll().isEmpty() before sendSyncAudioState() was redundant: handleSyncAudioState() in :callkeep_core iterates its own live connections, so the call is already a no-op when there are none. The guard was also slightly wrong -- it reads the main-process shadow tracker, which is transiently empty right after replayConnectionStates() (async cross-process round-trip) and during a push->foreground handoff, so it would skip the audio re-sync exactly when a call exists in :callkeep_core but the main tracker has not learned of it yet. Call sendSyncAudioState() unconditionally; cost is one extra cheap intent to a :callkeep_core process the replay already woke.
Sibling cleanup to the ReplayConnectionStates rename: the audio command is the same one-way pull (the main process asks :callkeep_core to re-emit current audio device + mute state to a freshly attached delegate), not a two-way sync. Rename for symmetry with replayConnectionStates -- both now sit in onDelegateSet doing the same 'replay current state to the attached delegate' job: - CallkeepCore/InProcessCallkeepCore/CallServiceRouter sendSyncAudioState -> replayAudioState - PhoneConnectionService.sendSyncAudioState -> replayAudioState; handleSyncAudioState -> handleReplayAudioState (same in Standalone) - ServiceAction/StandaloneServiceAction SyncAudioState -> ReplayAudioState; PhoneServiceCommand/StandaloneServiceCommand SyncAudio -> ReplayAudio - update KDoc/comments/logs, docs/ and AGENTS.md No behaviour change. Gradle unit tests green.
…tionReported (#334) The internal CallLifecycleEvent borrowed the name of the PUBLIC delegate callback didPushIncomingCall, but the event is really a cross-process connection-lifecycle fact ('an incoming PhoneConnection was created in :callkeep_core') and its handler does registration + delivery, not just the push notification. Rename the internal event + handler to say what they are; the public PDelegateFlutterApi.didPushIncomingCall callback is the host contract and is left untouched - it is now used only at the delivery boundary. - CallLifecycleEvent.DidPushIncomingCall -> IncomingConnectionReported (app-scoped broadcast action; both backends), handler handleCSReportDidPushIncomingCall -> handleCSIncomingConnectionReported - split the handler into registerIncomingConnection (promote + wakelock + resolve pending Pigeon callback) and deliverIncomingToDelegate (the public didPushIncomingCall, with the existing app-reported suppression). Same single broadcast, same order -> atomic register+deliver preserved, no ordering change - update comments and docs/ No behaviour change. Gradle unit tests green.
…er dormant (#335) The delivery-to-Flutter-delegate behaviour was not documented. Add a 'Delivery to the Flutter delegate' section to incoming-call-handling.md: a matrix of how the incoming call reaches a delegate per context (foreground signaling -> own Dart event, live didPushIncomingCall suppressed; push->foreground handoff -> onDelegateSet replay; background -> IncomingCallService direct; SMS -> live didPushIncomingCall) and the invariant that ForegroundService is the Activity-bound foreground listener, so its live IncomingConnectionReported -> didPushIncomingCall delivery is foreground-only and (with SMS not in use) has no remaining live consumer - the event stays load-bearing only for registration. Also mark the SMS-based incoming trigger (SmsReceptionConfigBootstrapApi / IncomingCallSmsTriggerReceiver) as dormant / not tested / likely deprecated, and cross-reference it from background-services.md, pigeon-apis.md and plugin.md. Docs only; no code change.
…324) * fix(android): adopt ringing incoming call on Flutter delegate attach During a push->foreground isolate handoff the call exists only as a native RINGING connection. The DidPushIncomingCall that seeds CallBloc was delivered to the now-disposed push isolate (or suppressed for the signaling-registered call), so the freshly-attached Activity CallBloc never learns about it. An incoming hangup then finds no ActiveCall and is dropped, leaving either an orphaned ringtone (no UI) or, once the late handshake creates the call, a ghost incoming that persists after the caller cancelled. Extend handleSyncConnectionState (which already re-emits AnswerCall for answered connections when the delegate attaches) to also re-deliver STATE_RINGING incoming calls via a new ReEmitIncomingCall event, routed straight to didPushIncomingCall and bypassing the signaling-registered suppression. This reuses the existing Flutter push-seed path (deduplicated by callId), so the call is present in state before any signaling event is processed: the hangup is handled normally and the handshake no longer resurrects it. * fix(android): mirror connection state on replay, not only the setup event handleReplayConnectionStates re-fired AnswerCall / ReEmitIncomingCall but not ConnectionStateChanged. Since the state-mirror refactor markAnswered() is a guard only (it no longer stamps connectionStates), so the replay stopped repopulating the main-process shadow tracker's connection state. On a cold start the original onStateChanged fired before the main process existed and is never re-delivered, so reportNewIncomingCall's already-answered adoption - which reads getState() == STATE_ACTIVE - could miss an answered call and treat it as still ringing. For each connection, emit ConnectionStateChanged with the live Telecom state (mirrored generically, also covering DIALING/HOLDING) before the delegate-facing setup event. Restores the connectionStates invariant the adoption path relies on and aligns the replay with the state-mirror model. Idempotent. Also switch the per-connection branch to a when. * refactor(android): rename ReEmitIncomingCall to ReplayIncomingCall Match the replay vocabulary (replayConnectionStates / replayAudioState): this event is emitted from the connection-state replay to re-deliver a still-ringing incoming call to a freshly attached delegate. CallLifecycleEvent.ReEmitIncomingCall -> ReplayIncomingCall; handler handleCSReEmitIncomingCall -> handleCSReplayIncomingCall. * refactor(android): make IncomingConnectionReported register-only, drop reportedIncoming guard With SMS-triggered calls not used in production, the live didPushIncomingCall delivery from IncomingConnectionReported has no foreground consumer: a foreground incoming always reaches the Flutter delegate via its own signaling (__onCallSignalingEventIncoming) or, on a push->foreground handoff, via the ReplayIncomingCall replay on delegate attach; background incoming is shown by IncomingCallService directly. - handleCSIncomingConnectionReported is now register-only (promote + wakelock + resolve pending Pigeon callback); it no longer notifies the delegate. - deliverIncomingToDelegate becomes the single, unconditional foreground delivery point, used only by handleCSReplayIncomingCall. - Remove the now-pointless reportedIncoming suppression guard entirely (reportedIncomingCallIds + markReportedIncoming + consumeReportedIncoming across tracker/ConnectionTracker/CallkeepCore/InProcessCallkeepCore and the ForegroundService call sites). The Dart CallBloc dedups incoming by callId. - Update the cold-start adoption unit test and docs. Call-critical -> needs device verify (every real foreground incoming must arrive via signaling or the handoff replay). Gradle unit tests green.
…tate replay (#336) * fix: suppress ghost incoming when call terminated before connection-state replay * fix: reject ghost incoming re-presentation for a just-ended call * fix: gate ghost-incoming suppression on never-presented end reason, not TTL Replaces the time-based recently-ended guard (which wrongly rejected legitimate same-callId reuse / blind transfer-back within the window) with a semantic one: reportEndCall(missedWhileConnecting) - emitted only when the app ends a call it never presented in Flutter state - arms a one-shot flag that reportNewIncomingCall consumes to reject a stale connection-state replay. A transfer-back reuses a call the app did know, so its end never arms the flag and re-report proceeds. * fix: make never-presented ghost guard sticky, not one-shot A stale handshake replays the dead incoming several times, so reportNewIncomingCall is called more than once for the same callId. The one-shot consume only rejected the first; a later replay still presented a ghost ring. Keep the flag set (peek, cleared on tearDown) so every re-presentation of a never-presented-ended callId is rejected. A transfer-back ends via a normal path that never arms the flag, so it stays unaffected.
… version placeholder)
…ed (#341) * fix: open app UI and swap notification when standalone call is answered * fix: route standalone notification answer through an activity trampoline
* fix: audio mode set for miui 12 legacy routing * fix: request audio focus for oem routing
…microphone (#342) * fix: promote standalone call service with phone-call type instead of microphone * docs: mark the standalone active-call phase for extraction into ActiveCallService
…alls (WT-1349) (#337) * feat(android): add background-activity-start permission for lock-screen calls On MIUI/HyperOS the incoming-call Activity cannot cover the lock screen unless the OEM 'display pop-up windows while running in background' capability (OP_BACKGROUND_START_ACTIVITY) is granted. The standard USE_FULL_SCREEN_INTENT path and setShowWhenLocked flags are accepted by the framework but still blocked by this gate. Expose a new CallkeepSpecialPermissions.backgroundActivityStart with a best-effort status read (reflection over the hidden AppOps op, Xiaomi-family only) and a deep link to the MIUI 'Other permissions' editor with fallback to app settings. Reports granted where the capability does not apply. WT-1349 * fix(android): report background-activity-start as unknown when unreadable The hidden MIUI AppOps op cannot be read on every build (greylisted on newer Android/HyperOS). Treating a failed read as denied told users the capability was off even after they enabled it, so the guidance never cleared. Return null (unknown) on read failure and map it to UNKNOWN, and accept MODE_DEFAULT and MODE_FOREGROUND as not-denied so a defer-to-policy state is not misreported. WT-1349 * fix(android): match Xiaomi family by substring, not exact equality Exact MANUFACTURER equality missed reported variants (e.g. 'Xiaomi Communications'). Use contains() for xiaomi/redmi/poco, matching the detection already used in CallDiagnostics, so the capability check applies on the same devices. WT-1349 * feat: expose xiaomi "showWhenLocked" permissions --------- Co-authored-by: Vladislav Komelkov <v.komelkov@webtrit.com>
… version placeholder)
…ve call (#345) * feat(ios): play call-waiting tone on second incoming call during active call A second incoming call during an active call had no audible indication on iOS: CallKit does not auto-play a call-waiting tone for VoIP calls, and playback sources started after the voice-processing engine enables play near-silent. Android already handles this scenario natively in the connection service; this brings iOS to the same fully-native model - no API changes, no app involvement. Detection: the plugin observes CXCallObserver and plays the tone while one call is connected (or held) and another incoming call is ringing, stopping as soon as that state ends - mirroring the Android connection-service logic. Playback: CallWaitingTonePlayer is a plain AVAudioPlayer (in-memory WAV, 440 Hz beep-beep loop) with two mitigations for the voice-processing quirk, verified audible on device over a live call: 1. the player is pre-warmed in the native CXProvider didActivateAudioSession callback, before the app starts the voice-processing engine; 2. the audio session category is re-asserted idempotently after every play. The tone stays local-only: device playback is part of the voice-processing echo-cancellation reference. * fix(ios): harden call-waiting tone detection and playback lifecycle Code-review fixes on top of the call-waiting tone feature: - Count only this app's own CallKit calls in the detection: CXCallObserver reports every call on the device (cellular, other VoIP apps), so the tone could fire on foreign call combinations. Own call UUIDs are tracked from reportNewIncomingCall / the VoIP push paths / performStartCallAction and pruned as calls end. Exposed as the iOS option callWaitingToneOwnCallsOnly (default true; pigeon files hand-extended additively). - Suppress the tone from the moment the user accepts the waiting call: the CXCall stays "ringing" until the answer roundtrip fulfills the action, so the beep used to bleed into the first seconds of the answered call. - Do not resume playback from the session-activation callback (provider's private queue) based on stale state; the plugin re-evaluates the call state on the observer queue instead. - Skip the play + session category re-assert when the player is already playing (it used to re-run a blocking audio-server call on every call-state event while the waiting state persisted). - Sync once right after attaching the call observer so a pre-existing connected+ringing state is reflected after setUp/restore. - Cleanups: DEBUG-only logging per package convention, shared halt helper, single indexing scheme in the tone synthesizer, corrected eager-creation comment. * fix(ios): pass the call-waiting option through the persisted-options converter Converters.m still called the pre-extension WTPIOSOptions factory selector, breaking the build; it now maps callWaitingToneOwnCallsOnly in both fromMap and toMap so the option also survives setUp restoration. * fix(ios): align the call-waiting tone pattern with Android Android plays ToneGenerator TONE_SUP_CALL_WAITING - a 440 Hz, 300 ms beep - re-fired by the connection service every 3 seconds. The iOS synthesizer used a double 200 ms beep on a 2.55 s loop; both platforms now produce the same single 440 Hz beep with a 3 s cadence. * docs: explain the iOS call-waiting tone design Distilled rationale for the playback mitigations (pre-warm before voice processing + category re-assert), the rejected alternatives, the detection semantics and the maintenance invariants - so the non-obvious iOS audio behavior behind them is not rediscovered the hard way.
* fix: allow self-managed calls to numbers Android classifies as emergency (WT-1730) (#349) * fix: allow self-managed calls to numbers Android classifies as emergency TelecomManager.placeCall() blocks a self-managed PhoneAccount from ever placing a call to a number the OS classifies as emergency, based purely on the tel: address matching the device/SIM-region emergency-number list - regardless of whether the number is actually reachable as one. This silently drops calls to legitimate internal PBX extensions that happen to collide with that list (e.g. an extension literally named 112 or 911). Building the outgoing Uri as a sip: address instead of tel: sidesteps the check entirely, since it only matches tel: addresses. The real number keeps flowing unchanged via CallMetadata into onCreateOutgoingConnection, which never reads the Uri - so this only affects what Telecom itself sees for its own emergency classification, not what actually gets dialed. * build: use portadialer.internal instead of webtrit.invalid for the decoy sip: host Same RFC-reserved, never-resolving intent (RFC 9476 .internal instead of RFC 2606 .invalid), just a more identifiable name for this address in logs. * build: remove dead emergency-number exception handling on Android The sip: scheme change means Android's Telecom never classifies our outgoing calls as emergency anymore, so the code path that used to catch and report an emergency-number failure can never run: - removed EmergencyNumberException and its throw/catch sites - removed the now-single-case-only OutgoingFailureType.EMERGENCY_NUMBER - removed the now-unused TelephonyUtils.isEmergencyNumber() helper The shared PCallRequestErrorEnum.emergencyNumber / CallkeepCallRequestError.emergencyNumber wire values are left in place deliberately: the Kotlin side encodes this enum by explicit raw Int values while the Dart side decodes by list index, so removing an entry from the middle would require re-aligning every value after it by hand on both sides to avoid a silent wire-format mismatch. Nothing on the Android side sets this value anymore either way. * build: centralize outgoing decoy sip: Uri construction in TelephonyUtils * build: percent-encode the number in the outgoing sip: Uri * build: bump version build iteration to 1.3.1+1
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.
Overview
Release 1.3.1: patch release cut from develop for QA.
Includes since 1.3.0: