Skip to content

build: create a new release version 1.3.1#347

Draft
SERDUN wants to merge 53 commits into
mainfrom
release/1.3.1
Draft

build: create a new release version 1.3.1#347
SERDUN wants to merge 53 commits into
mainfrom
release/1.3.1

Conversation

@SERDUN

@SERDUN SERDUN commented Jul 6, 2026

Copy link
Copy Markdown
Member

Overview

Release 1.3.1: patch release cut from develop for QA.

Includes since 1.3.0:

SERDUN and others added 30 commits May 11, 2026 10:48
…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
…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.
SERDUN and others added 22 commits June 17, 2026 12:43
…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.
…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>
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants