Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ bump may contain breaking changes, and each one is listed below.
route changes, and the published audio route. PJSIP no longer appears in the
audio-session code path — the coordinator asks its delegate (the client) to
open or drop the sound device on the SIP queue. No public API changes.
- Per-call state transitions and the current-call projection (aggregate state,
current call UUID, caller identity, microphone mute) moved out of
`CallWaveClient` into a new internal `CallWaveCallStateMachine`, with the
client as its delegate for state-change callbacks and events. This makes
call-state races (push/INVITE/cancel, concurrent reporting) testable in
isolation. No public API changes.

## [0.5.0] - 2026-08-15

Expand Down
76 changes: 76 additions & 0 deletions CallWaveKit/CallWaveCallStateMachine.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#import <Foundation/Foundation.h>

#import "CallWaveTypes.h"

NS_ASSUME_NONNULL_BEGIN

@class CallWaveCall;
@class CallWaveCallRegistry;
@class CallWaveCallStateMachine;

/// The machine owns the transition; the delegate owns the side effects that
/// follow it — the client delegate callback, the public event and statistics
/// sampling.
@protocol CallWaveCallStateMachineDelegate <NSObject>

- (void)callStateMachine:(CallWaveCallStateMachine *)machine
didPublishState:(CallWaveCallState)state
forUUID:(NSUUID *)uuid;

@end

/// Call-state ownership that used to live inline in `CallWaveClient`:
///
/// - the per-call `state` written into the registry;
/// - the aggregate `state` the client reports;
/// - the "current call" projection (`currentCallUUID`, `currentCaller`,
/// `microphoneMuted`) and the rules for adopting, re-pointing and resetting
/// it — including the cancellation-window case where the record outlives the
/// user's decision;
/// - the resolution policy for argument-less call actions.
///
/// Every method must run on the main queue, exactly like the code it replaces.
/// The machine emits no events and touches no PJSIP: both stay with the
/// delegate, which is what makes every race here unit-testable.
@interface CallWaveCallStateMachine : NSObject

- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithRegistry:(CallWaveCallRegistry *)registry NS_DESIGNATED_INITIALIZER;

@property (nonatomic, weak, nullable) id<CallWaveCallStateMachineDelegate> delegate;

/// The last published state, matching the historical aggregate `callState`.
@property (nonatomic, assign, readonly) CallWaveCallState state;
@property (nonatomic, strong, nullable, readonly) NSUUID *currentCallUUID;
@property (nonatomic, copy, nullable, readonly) NSString *currentCaller;
@property (nonatomic, assign, readonly, getter=isMicrophoneMuted) BOOL microphoneMuted;

/// Applies `state` to the call, adopts it as current when it is the current
/// call or none is, updates the aggregate and notifies the delegate.
- (void)publishState:(CallWaveCallState)state forUUID:(NSUUID *)uuid;

/// Points the projection at `call` without touching its state — the incoming
/// flows do this the moment a call is registered.
- (void)adoptCurrentCall:(CallWaveCall *)call;

/// Moves the projection off `uuid` to the most recent remaining call, without
/// touching the registry — for a call whose record has to outlive the user's
/// decision (a cancellation waiting for its late INVITE).
- (void)detachIfCurrentUUID:(nullable NSUUID *)uuid;

/// Removes the call from the registry and detaches it if it was current.
- (void)clearCallWithUUID:(nullable NSUUID *)uuid;

/// Engine stop and CallKit provider reset: every call is gone.
- (void)resetToIdle;

/// Mirrors a per-call mute into the projection when the call is current.
- (void)setMicrophoneMuted:(BOOL)muted forCall:(CallWaveCall *)call;

/// Resolves the call an argument-less action should act on: the explicit UUID,
/// otherwise the current call, otherwise the most recent one.
- (nullable CallWaveCall *)resolveCallForUUID:(nullable NSUUID *)uuid;

@end

NS_ASSUME_NONNULL_END
77 changes: 77 additions & 0 deletions CallWaveKit/CallWaveCallStateMachine.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#import "CallWaveCallStateMachine.h"

#import "CallWaveCallRegistry.h"

@implementation CallWaveCallStateMachine {
CallWaveCallRegistry *_registry;
}

- (instancetype)initWithRegistry:(CallWaveCallRegistry *)registry {
self = [super init];
if (self) {
_registry = registry;
_state = CallWaveCallStateIdle;
}
return self;
}

- (void)publishState:(CallWaveCallState)state forUUID:(NSUUID *)uuid {
CallWaveCall *call = [_registry callForUUID:uuid];
if (call != nil) {
call.state = state;
if ([uuid isEqual:_currentCallUUID] || _currentCallUUID == nil) {
_currentCallUUID = uuid;
_currentCaller = call.displayName;
_microphoneMuted = call.microphoneMuted;
}
}
_state = state;
[self.delegate callStateMachine:self didPublishState:state forUUID:uuid];
}

- (void)adoptCurrentCall:(CallWaveCall *)call {
_currentCallUUID = call.uuid;
_currentCaller = call.displayName;
}

- (void)detachIfCurrentUUID:(NSUUID *)uuid {
if (uuid == nil || ![uuid isEqual:_currentCallUUID]) {
return;
}
CallWaveCall *next = [_registry mostRecentCall];
_currentCallUUID = next.uuid;
_currentCaller = next.displayName;
_microphoneMuted = next != nil ? next.microphoneMuted : NO;
}

- (void)clearCallWithUUID:(NSUUID *)uuid {
if (uuid == nil) {
return;
}
[_registry removeCallWithUUID:uuid];
[self detachIfCurrentUUID:uuid];
}

- (void)resetToIdle {
_currentCallUUID = nil;
_currentCaller = nil;
_microphoneMuted = NO;
_state = CallWaveCallStateIdle;
}

- (void)setMicrophoneMuted:(BOOL)muted forCall:(CallWaveCall *)call {
if ([call.uuid isEqual:_currentCallUUID]) {
_microphoneMuted = muted;
}
}

- (nullable CallWaveCall *)resolveCallForUUID:(NSUUID *)uuid {
if (uuid != nil) {
return [_registry callForUUID:uuid];
}
NSUUID *current = _currentCallUUID;
CallWaveCall *call = current != nil ? [_registry callForUUID:current] : nil;
return call ?: [_registry mostRecentCall];
}

@end
102 changes: 48 additions & 54 deletions CallWaveKit/CallWaveClient.m
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#import "CallWaveCallRegistry.h"
#import "CallWaveCallQualityInternal.h"
#import "CallWaveCallStateMachine.h"
#import "CallWaveCallStatisticsInternal.h"
#import "CallWaveError.h"
#import "CallWaveEventInternal.h"
Expand Down Expand Up @@ -202,17 +203,18 @@ static void dispatchMain(dispatch_block_t block) {
}

@interface CallWaveClient () <CXProviderDelegate, PKPushRegistryDelegate,
CallWaveAudioSessionCoordinatorDelegate>
CallWaveAudioSessionCoordinatorDelegate,
CallWaveCallStateMachineDelegate>
@property (nonatomic, strong, nullable, readwrite) CallWaveConfiguration *configuration;
@property (nonatomic, copy, readwrite) CallWaveEngineConfiguration *engineConfiguration;
@property (nonatomic, assign, readwrite) CallWaveIntegrationOptions integrationOptions;
@property (nonatomic, assign, readwrite, getter=isRunning) BOOL running;
@property (nonatomic, assign, readwrite) CallWaveRegistrationState registrationState;
@property (nonatomic, strong, nullable, readwrite) NSError *registrationError;
@property (nonatomic, assign, readwrite) CallWaveCallState callState;
@property (nonatomic, copy, nullable, readwrite) NSString *currentCaller;
@property (nonatomic, strong, nullable, readwrite) NSUUID *currentCallUUID;
@property (nonatomic, assign, readwrite) BOOL microphoneMuted;
/// Owns per-call state transitions and the current-call projection. The public
/// `callState`, `currentCallUUID`, `currentCaller` and `microphoneMuted` are
/// pass-through accessors implemented on it.
@property (nonatomic, strong) CallWaveCallStateMachine *callStateMachine;
@property (nonatomic, strong, nullable, readwrite) CXProvider *provider;
@property (nonatomic, strong) CXCallController *callController;
@property (nonatomic, strong) CallWaveCallRegistry *registry;
Expand Down Expand Up @@ -283,10 +285,11 @@ - (instancetype)initWithConfiguration:(CallWaveConfiguration *)configuration
_registry = [[CallWaveCallRegistry alloc] init];
_audioCoordinator = [[CallWaveAudioSessionCoordinator alloc] init];
_audioCoordinator.delegate = self;
_callStateMachine = [[CallWaveCallStateMachine alloc] initWithRegistry:_registry];
_callStateMachine.delegate = self;
_eventObservers = [NSMutableDictionary dictionary];
_callQualityWarningLatches = [NSMutableDictionary dictionary];
_registrationState = CallWaveRegistrationStateStopped;
_callState = CallWaveCallStateIdle;
_defaultCallerName = CallWaveFallbackCallerName;
_answerTimeout = CallWaveDefaultAnswerTimeout;
_acceptDelay = CallWaveDefaultAcceptDelay;
Expand Down Expand Up @@ -1002,10 +1005,7 @@ - (void)stop {
self.running = NO;
self.registrationState = CallWaveRegistrationStateStopped;
self.registrationError = nil;
self.callState = CallWaveCallStateIdle;
self.currentCallUUID = nil;
self.currentCaller = nil;
self.microphoneMuted = NO;
[self.callStateMachine resetToIdle];
@synchronized (CallWaveClient.class) {
if (gActiveClient == self) {
gActiveClient = nil;
Expand Down Expand Up @@ -1226,6 +1226,26 @@ - (void)emitEvent:(CallWaveEvent *)event {

#pragma mark - Call state

// State ownership lives in CallWaveCallStateMachine; these are the public and
// internal pass-throughs plus the delegate hook that carries the side effects
// (client delegate callback, event, statistics sampling).

- (CallWaveCallState)callState {
return self.callStateMachine.state;
}

- (nullable NSUUID *)currentCallUUID {
return self.callStateMachine.currentCallUUID;
}

- (nullable NSString *)currentCaller {
return self.callStateMachine.currentCaller;
}

- (BOOL)isMicrophoneMuted {
return self.callStateMachine.microphoneMuted;
}

- (NSArray<NSUUID *> *)activeCallUUIDs {
NSArray<CallWaveCall *> *calls = [self.registry.allCalls sortedArrayUsingComparator:
^NSComparisonResult(CallWaveCall *lhs, CallWaveCall *rhs) {
Expand All @@ -1252,27 +1272,17 @@ - (NSString *)callerForCallWithUUID:(NSUUID *)uuid {

/// Resolves the UUID an argument-less call action should act on.
- (nullable CallWaveCall *)resolveCallForUUID:(nullable NSUUID *)uuid {
if (uuid != nil) {
return [self.registry callForUUID:uuid];
}
NSUUID *current = self.currentCallUUID;
CallWaveCall *call = current != nil ? [self.registry callForUUID:current] : nil;
return call ?: self.registry.mostRecentCall;
return [self.callStateMachine resolveCallForUUID:uuid];
}

/// Must run on the main queue.
- (void)publishCallState:(CallWaveCallState)state forUUID:(NSUUID *)uuid {
CallWaveCall *call = [self.registry callForUUID:uuid];
if (call != nil) {
call.state = state;
if ([uuid isEqual:self.currentCallUUID] || self.currentCallUUID == nil) {
self.currentCallUUID = uuid;
self.currentCaller = call.displayName;
self.microphoneMuted = call.microphoneMuted;
}
}
self.callState = state;
[self.callStateMachine publishState:state forUUID:uuid];
}

- (void)callStateMachine:(CallWaveCallStateMachine *)machine
didPublishState:(CallWaveCallState)state
forUUID:(NSUUID *)uuid {
id<CallWaveClientDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(callWaveClient:didChangeCallState:uuid:)]) {
[delegate callWaveClient:self didChangeCallState:state uuid:uuid];
Expand All @@ -1292,22 +1302,15 @@ - (void)clearCallWithUUID:(nullable NSUUID *)uuid {
if (uuid == nil) {
return;
}
[self.registry removeCallWithUUID:uuid];
[self.callStateMachine clearCallWithUUID:uuid];
[self.callQualityWarningLatches removeObjectForKey:uuid];
[self detachCurrentCallIfItIs:uuid];
}

/// Moves `currentCallUUID` off `uuid` without touching the registry, for a call
/// whose record has to outlive the user's decision — a cancellation waiting for
/// its late INVITE. Must run on the main queue.
- (void)detachCurrentCallIfItIs:(nullable NSUUID *)uuid {
if (uuid == nil || ![uuid isEqual:self.currentCallUUID]) {
return;
}
CallWaveCall *next = self.registry.mostRecentCall;
self.currentCallUUID = next.uuid;
self.currentCaller = next.displayName;
self.microphoneMuted = next != nil ? next.microphoneMuted : NO;
[self.callStateMachine detachIfCurrentUUID:uuid];
}

#pragma mark - Incoming-only calling
Expand Down Expand Up @@ -1661,9 +1664,7 @@ - (BOOL)setMicrophoneMuted:(BOOL)muted error:(NSError **)error {
return NO;
}
dispatchMain(^{
if ([call.uuid isEqual:self.currentCallUUID]) {
self.microphoneMuted = muted;
}
[self.callStateMachine setMicrophoneMuted:muted forCall:call];
});
return YES;
}
Expand All @@ -1682,8 +1683,8 @@ - (void)setMicrophoneMuted:(BOOL)muted
[self performSIPAsync:^{
BOOL applied = [self applyMicrophoneMuted:muted toCall:call];
dispatchMain(^{
if (applied && [call.uuid isEqual:self.currentCallUUID]) {
self.microphoneMuted = muted;
if (applied) {
[self.callStateMachine setMicrophoneMuted:muted forCall:call];
}
[self complete:completion
error:applied ? nil
Expand Down Expand Up @@ -2219,8 +2220,7 @@ - (void)prepareIncomingCallWithUUID:(NSUUID *)uuid caller:(NSString *)caller {
[self.registry removeCallWithUUID:orphan.uuid];
}
}
self.currentCallUUID = uuid;
self.currentCaller = call.displayName;
[self.callStateMachine adoptCurrentCall:call];
[self publishCallState:CallWaveCallStateIncoming forUUID:uuid];
[self configureAudioSessionWithError:NULL];
[self scheduleIncomingCallTimeoutForUUID:uuid];
Expand Down Expand Up @@ -2273,8 +2273,7 @@ - (void)reportIncomingCallWithUUID:(NSUUID *)uuid
if (callId != CallWaveSIPCallIdInvalid) {
[self.registry bindCallId:callId toUUID:uuid];
}
self.currentCallUUID = uuid;
self.currentCaller = call.displayName;
[self.callStateMachine adoptCurrentCall:call];

if (call.reportedToCallKit) {
[self complete:completion error:nil];
Expand Down Expand Up @@ -2428,10 +2427,7 @@ - (void)providerDidReset:(CXProvider *)provider {
[self hangupSIPCall:call.callId];
}
}];
self.currentCallUUID = nil;
self.currentCaller = nil;
self.microphoneMuted = NO;
self.callState = CallWaveCallStateIdle;
[self.callStateMachine resetToIdle];
}

- (void)provider:(CXProvider *)provider performStartCallAction:(CXStartCallAction *)action {
Expand Down Expand Up @@ -2484,8 +2480,8 @@ - (void)provider:(CXProvider *)provider performSetMutedCallAction:(CXSetMutedCal
[self performSIPAsync:^{
BOOL applied = [self applyMicrophoneMuted:action.muted toCall:call];
dispatchMain(^{
if (applied && [call.uuid isEqual:self.currentCallUUID]) {
self.microphoneMuted = action.muted;
if (applied) {
[self.callStateMachine setMicrophoneMuted:action.muted forCall:call];
}
applied ? [action fulfill] : [action fail];
});
Expand Down Expand Up @@ -2543,8 +2539,7 @@ - (void)handleIncomingSIPCall:(pjsua_call_id)callId caller:(NSString *)caller {
pending.caller = caller;
pending.displayName = [self displayNameForCaller:caller];
}
self.currentCallUUID = pending.uuid;
self.currentCaller = pending.displayName;
[self.callStateMachine adoptCurrentCall:pending];
[self publishCallState:CallWaveCallStateIncoming forUUID:pending.uuid];
return;
}
Expand All @@ -2561,8 +2556,7 @@ - (void)handleIncomingSIPCall:(pjsua_call_id)callId caller:(NSString *)caller {
call.caller = caller;
call.displayName = [self displayNameForCaller:caller];
[self.registry bindCallId:callId toUUID:uuid];
self.currentCallUUID = uuid;
self.currentCaller = call.displayName;
[self.callStateMachine adoptCurrentCall:call];
[self publishCallState:CallWaveCallStateIncoming forUUID:uuid];
id<CallWaveClientDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(callWaveClient:didReceiveCallFrom:uuid:)]) {
Expand Down
Loading