diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd432c..ba30317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CallWaveKit/CallWaveCallStateMachine.h b/CallWaveKit/CallWaveCallStateMachine.h new file mode 100644 index 0000000..c0ceee9 --- /dev/null +++ b/CallWaveKit/CallWaveCallStateMachine.h @@ -0,0 +1,76 @@ +#import + +#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 + +- (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 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 diff --git a/CallWaveKit/CallWaveCallStateMachine.m b/CallWaveKit/CallWaveCallStateMachine.m new file mode 100644 index 0000000..8f6ef41 --- /dev/null +++ b/CallWaveKit/CallWaveCallStateMachine.m @@ -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 diff --git a/CallWaveKit/CallWaveClient.m b/CallWaveKit/CallWaveClient.m index 5bd98d0..e1d0c8f 100644 --- a/CallWaveKit/CallWaveClient.m +++ b/CallWaveKit/CallWaveClient.m @@ -2,6 +2,7 @@ #import "CallWaveCallRegistry.h" #import "CallWaveCallQualityInternal.h" +#import "CallWaveCallStateMachine.h" #import "CallWaveCallStatisticsInternal.h" #import "CallWaveError.h" #import "CallWaveEventInternal.h" @@ -202,17 +203,18 @@ static void dispatchMain(dispatch_block_t block) { } @interface CallWaveClient () + 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; @@ -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; @@ -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; @@ -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 *)activeCallUUIDs { NSArray *calls = [self.registry.allCalls sortedArrayUsingComparator: ^NSComparisonResult(CallWaveCall *lhs, CallWaveCall *rhs) { @@ -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 delegate = self.delegate; if ([delegate respondsToSelector:@selector(callWaveClient:didChangeCallState:uuid:)]) { [delegate callWaveClient:self didChangeCallState:state uuid:uuid]; @@ -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 @@ -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; } @@ -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 @@ -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]; @@ -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]; @@ -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 { @@ -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]; }); @@ -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; } @@ -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 delegate = self.delegate; if ([delegate respondsToSelector:@selector(callWaveClient:didReceiveCallFrom:uuid:)]) {