Skip to content

build: create a new release version 1.3.4 - #369

Draft
SERDUN wants to merge 69 commits into
mainfrom
release/1.3.4
Draft

build: create a new release version 1.3.4#369
SERDUN wants to merge 69 commits into
mainfrom
release/1.3.4

Conversation

@SERDUN

@SERDUN SERDUN commented Sep 1, 2026

Copy link
Copy Markdown
Member

Overview

Release 1.3.4 of the call plugin. It gathers the Android work done since 1.3.3:

  • an active-call service restarted with no calls metadata now tears the connection services down and stops instead of leaving an undismissable notification behind (WT-1139);
  • each call's state is kept in one atomic record instead of several parallel maps (WT-1138);
  • the two delegate callbacks Android never delivers - continueStartCallIntent and didReset - are gone from the Android channel, and the platform interface documents them as iOS-only. The public CallkeepDelegate API is unchanged.

The rest is tests and documentation: the call-state tracker and the notification/background-service docs now match the code, and the plugin is tested against a stand-in platform rather than a live one.

No iOS changes since 1.3.3.

Verification

Analyze clean, Flutter and Android native unit tests pass on develop.

This branch is cut from a develop that carries main as an ancestor, so the version bump is the only thing between the two and there is nothing to resolve.

Kept as a draft for the QA period.

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 29 commits June 17, 2026 19:24
…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.
…ncy (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
…ecom forks (#353)

Some phone makers customize the call system to treat any number that looks like
an emergency number as an emergency call, even when it is a legitimate internal
extension (for example an extension "112"). On those devices such calls were
silently taken over by the built-in phone app and never connected.

Outgoing calls now carry a masked placeholder as the call address so that check
finds no number to act on, while the real number keeps flowing unchanged. A
dedicated component owns the masking so it is applied consistently in one place.

Verified with unit tests; on-device confirmation on an affected device is pending.
)

The shortcut that takes the user to the permission for showing calls over the
lock screen dropped them on the settings home page instead, leaving them to
hunt for the toggle themselves. The system needs to be told which app the
screen is for; now it is, and the shortcut lands exactly on that toggle.
…otification (WT-1825) (#355)

* fix(android): open the call screen when a call is answered from the notification

Answering an incoming call from the notification while the phone was in use left
the caller in silence: the call was marked as answered, but the app never opened
and nothing was sent to the server, so the call eventually timed out and was even
recorded as missed. It only worked when the screen was locked, because the system
opened the app itself in that case.

Newer Android releases no longer let an app open a screen on its own once it has
been sent to the background, which is what the app relied on. The Answer button
now opens the call screen as part of the tap that answers the call, which is the
way the platform expects it to be done. Declining is unchanged.

* fix(android): keep opening the call screen even if forwarding the answer fails
…#358)

With two calls in a row the buttons of the older notification could act on the
newer call: the system reused the same pending intents for both, because it tells
them apart by the button and the target, not by which call they carry. Pressing
answer on the first notification then answered the second call.

Each call's buttons now get their own pending intents.
…all is answered (WT-1825) (#357)

* fix(android): take the answer button away once the call is answered

After answering an incoming call from the notification, the notification kept
offering "Answer" and "Decline" for as long as it took the app to finish starting
- around fourteen seconds when the app had been closed. During that time the user
was still being asked to answer a call they had already taken, and pressing the
button again did answer it a second time.

The notification is now replaced by its silent form as soon as the call is
answered, whether the user pressed the button or answered from somewhere else -
the system call screen, a headset, a watch. It still describes the same call, it
just no longer offers anything to press.

* fix(android): update the call notification in place when the buttons are dropped

Taking the buttons away by detaching, cancelling and re-posting the notification
killed the app: the system refuses to put a service back in the foreground with a
notification id it has just been asked to cancel, and the crash took the whole
call down with it.

The notification the service is already showing is now rewritten instead, so it
stays in place from ringing to answered.

* fix(android): stop showing an answered call twice in the notification shade

Once a call was answered the shade held two entries for it: the one that had
announced it as incoming, now silent and buttonless, and the one for the call in
progress. The first stayed until the app had finished starting - four seconds on a
fast phone, around fourteen on a slow one.

It had to stay that long because it is what kept the incoming-call service in the
foreground. Now, as soon as the call in progress has a notification of its own, the
incoming-call service gives its notification up and keeps running quietly until the
call is handed over.
…vior (WT-1138) (#362)

* docs(android): refresh connection tracker and core facade docs

The two documents describing the main-process call-state layer had drifted
from the implementation: they still described the removed explicit terminated
set and missed the derived-termination model, the guard-reset rules, the
standalone backend routing and half of the current API. They now record the
actual behavior, the invariants the rest of the plugin depends on, and the
threading/atomicity picture, verified against the sources.

* docs(android): correct review findings in the call-state layer docs

A verification pass against the sources found ten claims that contradicted
the code: the backend-selection rule missed the telephony fallback, one
promote site was mislabeled as an answered adoption, the facade test
coverage was overstated, a dropped event was described as handled, and the
replay command and several trigger lists were narrower than reality. All
corrected, and the long-standing process-boundary rule now states its one
real exception instead of contradicting the facade that relies on it.
Three behaviours of the call-state layer that the rest of the plugin relies
on had no test at all, or a test that only claimed to cover them. Before the
planned internal rework of this layer, each one is now pinned by a test that
was proven to fail when the behaviour it guards is broken: a state seen
before registration reads as ended rather than unknown, a mirrored state
survives the reset that happens when the same call re-registers, and ending
a call both clears every trace of it and lets the same id be used again.
* refactor(android): keep each call's state in one atomic record

The tracker used to spread one call's state across eight separate
collections; each was safe on its own, but a transition touched several of
them one after another, so a reader could in principle catch a call halfway
through a change. All facts about a call now live in a single immutable
record, and every transition replaces that record in one atomic step, so any
reader always sees a consistent picture. Behaviour is intentionally
unchanged: the full test suite, including the recently added pinning tests
for the layer's subtle semantics, passes without modifying a single test.

* refactor(android): move the per-call record into its own file

The record describing one call's complete state now lives beside the other
core-layer classes instead of being nested inside the tracker, so its
documentation stands on its own and the tracker file stays focused on the
transitions. Only the tracker can still create or store records.

* docs(android): describe the tracker's record model in its layer doc

The layer document still described the eight separate collections this
change replaces. It now describes the single per-call record, the atomic
transitions, and what deliberately remains non-atomic, and its test-coverage
section reflects the pinning tests that now guard the layer - so the
document and the code ship in the same change and never disagree on develop.

* test(android): widen the tracker's safety net around the record model

Four areas of the call-state layer had no direct coverage: the window where
a call the app already knows is registered a second time by the push path,
the dispatch guards that survive a call's termination, the mid-call metadata
merge, and - the point of the record rework itself - that a reader can no
longer catch a call halfway through a transition. Ten tests pin them now,
each proven to fail when the behaviour it guards is broken; the concurrency
one also fails against the previous implementation, demonstrating the race
the rework closed. The re-registration tests double as a tripwire for the
planned tightening of that window: changing it will turn them red on purpose.

* docs(android): reflect the widened test coverage in the layer doc
…WT-1139) (#365)

* docs(android): bring the notification and background-service docs in line with the code

The two documents describing the notification system and the call notification
services still described a design several reworks old: a builder and channel set
that no longer exists, a manager facade with different method signatures, and
service lifecycles from before the answered-call notification handoff.

Both documents now describe what the code actually does, including how the
active-call notification is started, updated and stopped, how the incoming-call
notification steps back once the call in progress has its own, and the known
way an active-call notification can be left behind after the system kills the
app mid-call.

* docs(android): correct review findings in the refreshed notification docs

A verification review of the refreshed documents caught ten places where the
new text still told the story wrong: the shared call list was attributed to
the wrong OS process, Telecom registration to the wrong component, the hang-up
button to data it does not actually use, the looping ringtone to a mechanism
that is silent, and the ring-or-vibrate choice as ring-and-vibrate. It also
caught statements the rewrite had left behind: an API table with a method that
no longer exists and outdated signatures, the disappearance of the service's
safety-net timeouts, and a notification channel left with no documented
producer.

All ten are corrected against the sources.
…(WT-1139) (#366)

* test(android): pin the stuck active-call notification after a service restart (WT-1139)

When the system restarts the active-call service after a process kill, the
user is left with a half-empty ongoing call notification that cannot be
swiped away, and Hang up does nothing. These tests reproduce that scenario
and pin the current behavior so the defect is executable and visible; the
upcoming fix is expected to flip the restart-path expectations.

Also enables Android resources in unit tests so notification content can be
built under Robolectric.

* test(android): state the fixed restart-path expectations as disabled tests (WT-1139)

The tests describing how the restart path must behave after the fix - stop
the empty service and remove its notification - are added commented out,
with a note to enable them together with the fix and retire the tests that
pin the defect. Verified that enabled as-is they fail on the current code
exactly where the defect lives.
…adata (WT-1139) (#367)

A system restart of the active-call service after a process kill used to
leave a half-empty ongoing notification that could not be swiped away, with
Hang up having no effect - only force-stopping the app removed it. The
service now detects such an empty restart, cleans up any surviving call leg,
removes the notification and stops itself; a Hang up tapped on a fresh
instance hangs up the specific call it was tapped for instead of tearing
everything down. The services document is updated to describe the guarded
behavior it previously listed as a known limitation.

Verified by the restart tests introduced with the reproduction change, now
asserting the corrected behavior, and the full native suite.
…#368)

The calling plugin offered two events that only an iPhone can produce:
continuing a call started from the system's own recents or voice
assistant, and the reset the system sends when it drops every call it
was tracking. On Android nothing has ever raised either of them, yet
both looked like part of the shared contract, inviting Android logic
that could never run.

Both are now removed from the Android side and marked as iOS-only in
the shared interface and the documentation, so the contract says what
actually happens. The version of the tool that produces the generated
message files is pinned alongside, so those files stay stable.
Releases 1.3.1 through 1.3.3 bumped the umbrella version only on their
release branches, so develop still read 1.3.0+0 while main is at 1.3.3+0.

Only webtrit_callkeep/pubspec.yaml carries a meaningful version; the
platform packages already match main and are left untouched.
Releases 1.3.1 through 1.3.3 landed on main as squash commits, so none of
them is an ancestor of develop and the merge base has sat at 1.3.0 ever
since. Every release PR therefore reopened the same resolved conflicts,
and a three-way merge against that stale base can silently duplicate code
that both sides added: merging main in here re-applied the
notifyActiveCallVisible block that #357 already put in develop, with no
conflict reported, because #367 had moved the surrounding code.

develop already contains everything main holds, so the merge resolves
entirely to the develop side and leaves the tree byte-identical. What it
changes is the history: main becomes an ancestor of develop, so the next
release branch cuts clean.
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